Merge branch 'master' into worktree-dual-market-score

# Conflicts:
#	.env.dev.sops
This commit is contained in:
Deeman
2026-02-24 16:38:25 +01:00
58 changed files with 1709 additions and 778 deletions

View File

@@ -1,75 +1,77 @@
#ENC[AES256_GCM,data:psJHjQ==,iv:SzniuUiZ9ztOuzloQbS3D5+8Im+j3k9u49m50vL0mMw=,tag:j5xsNGx0D1DN/6dORMnWFA==,type:comment]
APP_NAME=ENC[AES256_GCM,data:H1MZ8tUqqPlVk0U=,iv:M+mOWez35VFkwDrE7cyu3ENZuu4NMZxSYLPRjzKMCdc=,tag:0qPU4vxvrd9U4jTmFNKo2w==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:nQb/yIJi7Kj3p4hsp8j37ErOemIK0dYfSBbYpXrQrWw=,iv:JbaGPEU+FOVfi7b08rcVatl+9A88kVK28g3GBDYIbik=,tag:y7sMact08j0KU3/uvx/Xbg==,type:str]
BASE_URL=ENC[AES256_GCM,data:oR8gHKCWzxXm56xmhzxEG+1A9v49,iv:xkRDi4Xc6zzLenwPVSdtAgXO5NwxpI+ErCm2/iCZpEc=,tag:QKH8z76U8cGEnXbRjX6ZpQ==,type:str]
DEBUG=ENC[AES256_GCM,data:qbs/2g==,iv:qgj/DKZOlvq/1TWa/5zXKIhNCsBi9JRP+emdbannhsA=,tag:3SZSzp+yXdTX8PEOuhAb4w==,type:str]
#ENC[AES256_GCM,data:VK5ULFrGQOKZq8rTORAIqArsWZxqDJiWD8leVlUc99zonsyNc7DfDjNTZSp2lzFzhTDA0VXn8ix31q6dHdxmJxGfaZcby9K39Q==,iv:6XnwkeS9BNyb7VclP/U5ZetVdQQMSZt+igcYxkIQNms=,tag:sSBVde1gWk7rlJLav0bhRw==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:hwO8cFcaSrUQSrkWLg==,iv:c0xp8x/3YyyLPQ+RHZ0Rj9loAzzHycEXuF1285uCIqI=,tag:l+f6k0roog30S6VjD6SllQ==,type:str]
#ENC[AES256_GCM,data:Wj9txgkYZHWN,iv:yiINkaZd85ErmS2kNBaX++121hXkhGh1SyauchchaOc=,tag:SaYM9iLIq0LCuRa02/kZ5w==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:lo22+6QEP6wAH6k=,iv:/22i644cwmw1jZqy/pQpyMNsLQMfthhbbQkiWSFfQ90=,tag:LHo9snqVrtsAFEtmCng6VQ==,type:str]
#ENC[AES256_GCM,data:Pi5EYfM=,iv:+TwGY0l4GRLfqpPePuPavBC2Btlwp4DFLDkR99DYkRU=,tag:SZy9qSMRVmJdZDfjhEEAkw==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:h/M=,iv:M+6ZL+a6+1XxkF/ro126pzIR2MujAnKmJk+SYzKxOqM=,tag:zYw+xYG+pB5T9uxNM8sI8g==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:2aY=,iv:sF7N/6lLZ75fgdD9FmN6rQWjufq4NblykUKTIjaEGqU=,tag:OmCDVvSuxdymOAT3WduENQ==,type:str]
#ENC[AES256_GCM,data:DAy5JwrbyF81ZipoJI/9,iv:2xopxHFESeGkHf5GzF82w/+XgE/QT2VbTB0cHFbuIOY=,tag:yZxm5fys1oK8RpEQYKFHfQ==,type:comment]
#ENC[AES256_GCM,data:XouEHJ8o3qn7J7StbGmnVay1agj/PkIZXYUHBjz5KWsv3a/SJYyghMxGRv4DgKMrels+1ERT7aOeq5bjt3gl5VjeBKmhUvlcZDlRCw==,iv:f5CzFgweo7V4KLnmuxCtVC1Oos6fcZGeUqX1Jw/OPpI=,tag:Hrwf6oTei6aVYPtBdZo7HA==,type:comment]
#ENC[AES256_GCM,data:OODjUg==,iv:E1PqA4jzCrltGb9T8tiB2wrkLmzefekOVJt3jXze6bI=,tag:t+nmaZ+gsR0Qr3XeJ/szMg==,type:comment]
APP_NAME=ENC[AES256_GCM,data:MIW1LUcXGSRAXQo=,iv:WlHAjnFaoo5HgsyqGnvbiuMfvZIYXeA+ssw43fsP3TM=,tag:BOGuyhHj5+T1Tg7TBbCPHQ==,type:str]
SECRET_KEY=ENC[AES256_GCM,data:F43Bn1FzhYyzdGINTQA90Uw2aZWJ7REhVnM5Tc6KFS8=,iv:ZAbWVSCrVDKqVzPkNfAvH6p8iRISZSTD4U68JVZwL7Q=,tag:B5tmroCp3EJ+QDnGXgbXOg==,type:str]
BASE_URL=ENC[AES256_GCM,data:wmh0BoYwd3GutNhk7uQZKO4MAXin,iv:uZ4FivDic5KL7fQvrH1rhN4bTmXWqnHfzMJq7/4o+5w=,tag:NFfkEGkS1vxjODo/zB5Mcg==,type:str]
DEBUG=ENC[AES256_GCM,data:aqfTbw==,iv:g7jWtA4OD+b7QW4Sor7dX/6pJxif7d2SiU/An21QSfY=,tag:sjSyQQNtk0rLaOhYzNbAKw==,type:str]
#ENC[AES256_GCM,data:dSj2wIiLuhNq9FvDEhTL3F6LBJ4ITNsaKYO1YRmLZLDS3+C0jk7Hx5kPjcor4T9kst5tOE1KMH7HKn+0CHeWf+pdidAWikd1xA==,iv:hbnREdnOSgivoTI9/GwUOpRg1yROIgjbTpiR1R9u3AI=,tag:am/IFpc8a8Du0Wx8X7u9nA==,type:comment]
ADMIN_EMAILS=ENC[AES256_GCM,data:yS0kOYrM5VRhAAkxqg==,iv:eCqHA4kcgwt9T03Umq8MYiLYeB+Zh3pL9R7adcSZkYw=,tag:k3O0EbevHeWgG1JQAh30hw==,type:str]
#ENC[AES256_GCM,data:FDydGDKV+yuB,iv:3Q2JHDLqOWS/R+91Kx+5l7IST9DRngBd7Nd2Oau0kFw=,tag:JmavycCv5EeW7nDCum9SsA==,type:comment]
DATABASE_PATH=ENC[AES256_GCM,data:2INhi9u4WHoC6eA=,iv:JvkmUtrd5oxw0ZGQJAhI4kj1MtPqxRi5keGIzgCHcPQ=,tag:T7Yb1QKkVR3U7Ww5puKinA==,type:str]
#ENC[AES256_GCM,data:c3ikDZY=,iv:ssaKWl8+ddTa48pst3cz2n5ywMqMdrUN69jlLGRbgJU=,tag:7Dk53YiaBzYCyrrddUHb1A==,type:comment]
MAGIC_LINK_EXPIRY_MINUTES=ENC[AES256_GCM,data:gCw=,iv:u+tNE3Jc0GD1JJB7gX4MFelHb0JhqOiFAjP5cvsS1eU=,tag:hQJPei0T/Hz8eUnUmGxnyQ==,type:str]
SESSION_LIFETIME_DAYS=ENC[AES256_GCM,data:NT0=,iv:wzqXr3Stux9WrJBF89iYtmZMuoog2jr5ScTd+W3T9E4=,tag:Ei/FtIMAmBChc1Q6GS01SA==,type:str]
#ENC[AES256_GCM,data:0gHTU0jfkx3K/aeVQ4ab,iv:MhpRgozzMhtlDw05jDdPvNB9linCCep8WWPXFCxESI0=,tag:yxQncJ6uBHPGM2PsgsqA2w==,type:comment]
#ENC[AES256_GCM,data:N1R78wzjduwR6vj2/2io04RHvDz4bdMZQah2eG6iuQfe76dk4yVA2is6vIAO0wGHQbKOFj2XCFnex9h6LK7yfVB2TNNxndTLDOemlg==,iv:ifFHqlb2oY4flnWX3sDYKEFP3cufi5pfuF1H0zjZGRQ=,tag:ZJC6zkPE5/29GCpRK6G7QA==,type:comment]
#
#ENC[AES256_GCM,data:1bQM/Vz533qH7tZjIz9VcXgi6kJtw6GPDRTjUHgc/RKVvih8oyV5RQ3f8xADF4DPrj2SDluC1z3CkXfEJvagd8XyXO1DOcSaHqi+z1a2cOk=,iv:zQ26I2BoZuOEoqMGIQaEPfS1GMP4jlVeq71G5jUBBOY=,tag:TYjdXAaMOHPCrRSucotkug==,type:comment]
#ENC[AES256_GCM,data:K1Vaf74yKM2MGKDTVvbhoOjxfOlIjEQUq9/Xro76MTvujjmxYQhZN635e89MTCi6IvPayjmIhEy22afoV13SUFLJNgEr,iv:+dnDjnKd1l+QxbEbKBi98FpnSc6B57if/Wqp1niwWSY=,tag:BM91SOq6lW+0wmvRuJQsEw==,type:comment]
#ENC[AES256_GCM,data:mRUwmSBlgMpw5T/Tlf2pMRb/3pKBVW6oNBiDMrVk9lHpHw9xW+TmVuF7uNY7X6BUUX7BJN0=,iv:RpA3thinNI+pzJwVms2GFhEhRg5bPM+1Z+T9earmud8=,tag:HwBQco2W6HNad3WLAxIG6w==,type:comment]
#ENC[AES256_GCM,data:Hdi31ezbwtY9s9KKJ00f5Ufr+slTai8f2B8Kat5ETw0CQtfjggCmlC2wjSr5Y+JJi2sNQ+ykRKg=,iv:BlTIRcTEWtKrGMVJURASpFm+94KvBCAykDsU/uf4EZI=,tag:Ti561vluY63FcI4EjiNr9g==,type:comment]
#ENC[AES256_GCM,data:SjrFuaxF58UqhIyTyFDNFt3OaL2Q4H7RQiG2l+e1KAVIYCVd59Sx67NI9vanU0Yu7HQ+rC6YKUEBxwPrXRw=,iv:ySc7zPpWOcxIdIIYYgqQN1fUpz9Oo3jiC2Gdx+RP1sw=,tag:x4SKb70Lwo2OcChZWCfhvw==,type:comment]
#ENC[AES256_GCM,data:tJgYtYXnton0WydB+1WGpFbwOEgS0xlnTnqI292JW+wK3wmGrR7vpj57AJiium+KgDyDpQ8NPkM=,iv:bVZYkCtenqMZ/587qnhNbeRWx04YEwcYsafvTvWLEqw=,tag:CJFtbfeGb0tvYrZVMwoEdQ==,type:comment]
#ENC[AES256_GCM,data:QeO4YHJbRkvEb/U1l7mtbDplHX+fGFoN1OdBgrkVJKPoJBaF+GQOXFf1IlWohqqwWViwwlXGYWV28hiKZTqWA3SHQvJe0SIY,iv:gum2Sjb6rOyuYM1mcTtMZ4WoLubNfxPr5x1OqC9dPq0=,tag:WMvP7XmXaajFLDrzSeRuMQ==,type:comment]
#ENC[AES256_GCM,data:tuw7/hEPqtY59Y+L3Nq/Y6op26+U05GzZjQpGpwskwXJk5fauks66lPr+P9vlgEJXPOzuF1ZmB+M9td780mXavW49PKVh5NnjP/GbQ8t0Gc=,iv:wj6MCF/ujeaIoYC+pcgeW+slt1CBYPK5Lj4+LyHfehg=,tag:9EJJ46Exxd2On2SdCba+vQ==,type:comment]
#ENC[AES256_GCM,data:KApVqBUmJYwUpHp6BPBkzzVR+BPe8h/u79lODjPLvQoCT9XIfJ3ulZngRUUIDCJr6+yM4y98qxQJf7bhs4z7CK6oB1L/,iv:FAuFeIOD/Rx81L97cAqYIPZ3bhyrqOReQ5xeOjyN/m8=,tag:Z69JIWO4e/Q8+BQ5ThslVg==,type:comment]
#ENC[AES256_GCM,data:80vv1OoLoWwS+J9cbZ1Nt+UeF6Fc4D8LUHLX5BiEmBQAXh8ti3EZZnwi2QZb0wN+Ma1zwsM=,iv:6xnMzedX62XPU3GGpoTie8RZDFlzvrpFMc4u+fCbKYw=,tag:V7HtIqw1+ltW4dbvqobjQA==,type:comment]
#ENC[AES256_GCM,data:irTPd03a7pDOi2WOsL43GB3c/luAhlm0u6b3Wj/oLKnlzmmUV++aoyDum32wGLI4qH9Sy5XJS44=,iv:HH3uPdh0ZBdvX2bhH5w22TCsn67HaJa+/f5bk8NJNvA=,tag:f4xcS4URJS1EAipjcpFn2A==,type:comment]
#ENC[AES256_GCM,data:uW8E3Yhcepw3FJ3En5N8CLpN70a1ovXZJyS/gSqaWsMtScbTEqlMZ39R0S8hiS3j1y7ST7GaNXburXx0lv8=,iv:Xk8GhHPk7zVnMi4PbazefyxumHSv/XwpO/lk2SCxYEc=,tag:r5IpNFQ0wxQ05+UfOaenig==,type:comment]
#ENC[AES256_GCM,data:Ep/iNZ9JUQTFhk63ukNzs4YltDYEZ1b4jvi3yBpT2yfnSuVvrz1xSc+oU5WbHI8SeGbpaTgn5eA=,iv:vry5r45x5xNcIr1oyCbVAkFxFq2vpvvOEzqlvWI/bQc=,tag:MjTSas4nqwHg7J2ZfxlkfQ==,type:comment]
#ENC[AES256_GCM,data:OwkE2btYFBq2akgxnlHA3lVUJ4eXI4YVjIxlbK4lbrn6cd9U6xz7DhrDuFbOxMo1VtbyxBQ6dCBM5TODPHMFRBCIyuuXQi17,iv:emVmqGfwrpkF5HoQ0OD0UhiN076RezDEgsmBm2e5FZg=,tag:Hj+KHO9yKGqv2W5XkkHCZg==,type:comment]
#
#ENC[AES256_GCM,data:nCkfDGhY7EK+GgSMjQ1xqeN3/GloZ64sd8x4Hf8WXDdyccHQYydPqKsxuu8LMyag/Ycx1ih7tL/PS8qCZ1zaTgBCzsTXoRv4Srw=,iv:zIQTojLFkIiRgyeR8dhnJV30oaGnsqTegN/DtxjAlak=,tag:z5cv1rRd8a5Mx9TqTAKVLw==,type:comment]
#ENC[AES256_GCM,data:1rm7G2bjcRWiQBcHI3v6/iVxnASJKchxrEot8YznFxoaGcEQNyQQXapZUFRR02pT9FT+vy6mCWxDyqRdyrtdlGjIL+N5R0ukNcI=,iv:dAFaoyjPikp3gOCIph6UWVrAR3ascxMKr+weOCAqO0Y=,tag:a8aDQifsAp/6+uemGMwryg==,type:comment]
RESEND_API_KEY=
EMAIL_FROM=ENC[AES256_GCM,data:9wy7ErYyMpljVKkJz6Rqfe9+V3s=,iv:ODXcaOF3tyrTnDO9DZTvfj62vYRUIvVY2CHoYDn5IKA=,tag:U3bFiiOhR/Wm4qMl3qLHCg==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:AkeGWCkFfbm3HMbuHzbo08kySiw=,iv:jrVtZT4ASjBCXvaHmVmui9AFRH3Qa25U9964aFVdEJs=,tag:0w2W7Pt3q8iCg00CqRV/ZA==,type:str]
EMAIL_FROM=ENC[AES256_GCM,data:aKRFxdRPnDMs5ch7uYx2l2D5kwg=,iv:cxmNdrdXZnRjI0oDXUrz028mI7KnSUXK/pUz71NkUUA=,tag:UjKqpg6oq1t2WIdaXGHzaA==,type:str]
LEADS_EMAIL=ENC[AES256_GCM,data:BJ9r1b4E2Ck2b162f6K2P6z2rXs=,iv:NmrlbVJU7pfBf7pXc4No+69AyvqgF0j7vZEkSvYV6SE=,tag:fI3oyLbePaqi9TqkiJXZtA==,type:str]
RESEND_WEBHOOK_SECRET=
#ENC[AES256_GCM,data:Byl5Qzg2rnLJ+aubNtCPsku4C0g7ExYy4iWqRYvCuMQMo3XsO9jhxqGq5hKvsp2aMci+vegITJmc6jhYHyj9+KT7zg==,iv:KB+DYb8e1864ar0ggPVsNqkn8WPu/lUnMRUecK2I4yY=,tag:rZkjuePVfBvGvF8fVHXfeA==,type:comment]
#ENC[AES256_GCM,data:jMVH2QFeX3E8deSn4zkKfe43In7sHvhmGGzlFNyJ1g7kQM1orIHcMVPhE7olXd7aZQUlU7RHbUso8kXEFmABVGTmoeMsr+TVVRuO,iv:1M6qYVEpuDS3OIRmqDGUWgnu4QXmt6Jx7kqinQCosO8=,tag:aJEZ4hTyqBWbvSN7sf2p8g==,type:comment]
#ENC[AES256_GCM,data:p9j/Md/0l3xParwh/O3F+7MJHXcpNbqrXB5n1Vr+sYAlVbK072uanLCA3rRFbRsSC77MaB53imaFTwkYusMbgPyh0OdIVGR8LNucWw==,iv:EPJ4rRiPRpz2eu0jHSZiXmzvaa0pxSgl595ck+66rlc=,tag:xjd5YkLr7uPYBI+JA5ylEg==,type:comment]
#ENC[AES256_GCM,data:GJqhmvHWOyPYssCxpTKS/75/pa3AjaMDILOZJPfAPMJYbqCvxed30KOAfcPLa1EVySgv,iv:FuPcy9nlljN9rUh9y/lR43/rDxDC7Inswynb55wXTMY=,tag:CjdC2sgda8X0gGUde8+Axw==,type:comment]
#ENC[AES256_GCM,data:6T9lVhn+1mcyCxBYy/dsPaGp0zy8+XQRhRCFKxlos4QOIiLBjrYuI11v2oSO80Nn6AfGNms40ewi4RpHtt7Afawb3A==,iv:f9gfD7cM3WJGdvtFQ3Svi8Cui2yadmMXfuF6IAIn/LI=,tag:3jt5lFeI0P827LqQWUGzOA==,type:comment]
#ENC[AES256_GCM,data:ja0Rgj3IVihRU618EIroO3bJg9sWFOd3Ua88HLP9yrzEZ7Ty8Havd+2vroD/TGeidqjiFmpMlXH2R3v1jLrviRbkWqBlbBO4se4G,iv:IfuAx8HcJByEcLaqegqZZZVNfO9H8LnuPzIcXHYRBDY=,tag:FumQcnMmcKHI81FjYrT8Mw==,type:comment]
#ENC[AES256_GCM,data:eHDEBuHGW7rKMPW1NM9b47rBS9BMmtmrwICbijYIVdogMvqJCMEk8zfT/lc/bnXFiamFYfJhhHDNEEOBg69ZdZ7M+mWPCRps6cXP6A==,iv:7SDIHytbnp/v6zXG0j4PbkIIzjeDVqp4BKthmTIqF1M=,tag:+fPDhe9NIe2Qmqp91KuRjw==,type:comment]
#ENC[AES256_GCM,data:VrBNRBK97VxVTcwPZItM14tr5IbWQ2jGTLh1Hca4TPHGWPo75fxUa8MFWSCUcwXIKjMm,iv:FRa+BpQFtyx5BuNt609duHng+QgMbQavvOzDpPb0Ta8=,tag:+OiUXm7Vh+xhdfQ33KEhSQ==,type:comment]
PADDLE_API_KEY=
PADDLE_CLIENT_TOKEN=
PADDLE_WEBHOOK_SECRET=
PADDLE_NOTIFICATION_SETTING_ID=
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:MuOO7EEL3g==,iv:cSrV5iOjKDIN5Y9m4ueIo85Bf7j7aw6tlhNbLRl57UE=,tag:e7V+6z20M+VO1AaHeonyBw==,type:str]
#ENC[AES256_GCM,data:Lr1JNBJ+cPcQoG1rmGG+e/tlpTo++gAmyys9rchhOb6QwROjQ9dDVBn5g84fUDyuyphBwzDVTiQqWXDw,iv:xSTbE+0xBI2+bP8oFARbVR8DwieVpNPEdKX58+TKTp4=,tag:hdiD9jxRlIWg9wHVTAeSDg==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:jON8A4/+me0pK0zEleVHr/xIhS4vjnuyG/bYZw==,iv:sHSODCD696z5x2RPG+XwdbODmGqjO/ckSL96zEb5jIM=,tag:qDWqsVdZDKSb2HmjqX/C2g==,type:str]
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:Xm4mm3DBjg==,iv:6k4ZSP7GQF4LwVG0OAMBdInAfaf3hhJLjmIztNfdCWg=,tag:/LJeoFbovsvzP7GBEiLsTA==,type:str]
#ENC[AES256_GCM,data:1c0sgo2l+OssNz8Rgm8/DI5ormrHXNHKDTKx6bWmv/2CchefqxsRq1uS7uR5c86eTxfp023LGhy947i7,iv:PAEdaE/pgfy3jxuod1PJ6VcyIYOKmlKkPmudSs5xbxw=,tag:KYnQmbim+fWsnW6nlEnhGA==,type:comment]
UMAMI_API_URL=ENC[AES256_GCM,data:yj4OYkvZIDrgjgkdM3a8xyzvoGJOk75wUhAgNQ==,iv:JmQMdU9XZ5ABfvk0w3XP/WkdcR1KrgXTPVI39+drhO8=,tag:jW1MUL5Nv+z8KP9FjmYFcg==,type:str]
UMAMI_API_TOKEN=
#ENC[AES256_GCM,data:A3PruZsHqkm+J7kTN3k=,iv:KuesKLtHm06AkJqXfBqQlwljStMA2b07Kat+ffInuOA=,tag:kOGzYM0iQaOuIX45EG8+Ng==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:2AqE,iv:mBmz1eTVLfa+h9jHKq0x2pAdzf3DzBLIOjKOmqZc3gc=,tag:s0BoGPaOOk0d0mtKLy2KcA==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:iRY=,iv:mleQs4pjGMzvvaQMJw/XLOeKv5V+YiuAAjBu8gYy9uo=,tag:tnMtkUrzBiTmv++NDUJckQ==,type:str]
#ENC[AES256_GCM,data:X+0FYdGv5HtuCXvxBlTJ5H4Lo7q4je88sOtRfFVpCRllHqNC0U42Ki5JV1DcJ4srEpN0TRqBDrZHW+4tsM5FEEQXa5LwBrJEKK3GcuzJGkg=,iv:0CwGNS+vg5b7otbcD104PuTnZKgwkgY1Kz9KrKRxi94=,tag:vVmUWHfftlihGxNOm9pzMg==,type:comment]
#ENC[AES256_GCM,data:5nypMO9DFup4c3p0xXM=,iv:GdbOATtVOmFZKvDfv4gdNDDdq4WmB+/yk/D/xgC7Uos=,tag:TWfgxyuzlzvzxJRMF0L0cA==,type:comment]
RATE_LIMIT_REQUESTS=ENC[AES256_GCM,data:o/R0,iv:TGPynY3rpH5fL8zQvI9EAWg/LFv7earnaCbGhkkN2FE=,tag:lpnRZGaONv1TZdwJg17AIg==,type:str]
RATE_LIMIT_WINDOW=ENC[AES256_GCM,data:2dU=,iv:6wSl6GAWAVxfVC/LB0uqhB+8gtHhkrFybASXdQseuVk=,tag:gVjomO7E837h/plD/8948Q==,type:str]
#ENC[AES256_GCM,data:7CoH3Rv284IHofVG9bruk5NYF9S4oeGqupZFQ38OpfCde1p9ks7WGicFmfrMQDNTLG1YoR3TD/u/XQ+19SoP+XTcSlhoYwo2mx19ETWnww4=,iv:BvgKjGm1YB97I8EQG74uaoDk1QraWxTVtZoiNGkdx6U=,tag:vbS882eevc0rzOA9pGtzng==,type:comment]
LITESTREAM_R2_BUCKET=
LITESTREAM_R2_ACCESS_KEY_ID=
LITESTREAM_R2_SECRET_ACCESS_KEY=
LITESTREAM_R2_ENDPOINT=
#ENC[AES256_GCM,data:Unb/KW2eYRntrJ/TsLM=,iv:t6sCYKJfhNM+/84INlz0YVCI6L1bscYa73mE1rNxnnM=,tag:kGcntxt+RcbZxdJcNxQ3jg==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:ARCEMZ0Al6XHVc1C4ATlvxKpISNA,iv:szyyeg+k42alOW5iFzvxyZZUyLEhyN8RQKgfYtys+AU=,tag:JTRR6aZ3MT0XcoXOKoVZUw==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:Wcwm/Z2TbAmQwGr0FsnpCIdoN59+,iv:RnB5XsLybGzHR80/5gHvAoa+Bi3i68krIWpWtKXy+sU=,tag:EyPVwrOqKam8vZx5WYKxrg==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:yIO52yeXIoNFsWN5,iv:fpuzmDiDPA5xbs2toBV0CT5BlUHmqnJWLeBnusCL2hA=,tag:u4JJTa8SA6+faOob47GCcw==,type:str]
#ENC[AES256_GCM,data:CpK3Cz/uaQZtART3VGH12fpKKybZ7Esemsb0njTgLHOEXpiEiod2DXCms1Fbw+8jFnzrkkn3lzGZneTFZxuDHnlZJuOiwWZ2908=,iv:yhGEA6Bk5YG7NBOPwD+aC6HsG3deRUGq63sC9Q/EX+M=,tag:/8NDmA8nk2lt8pYGFJC45w==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:xw==,iv:2f43vpqTioOAedxB2FeSH5ICOsGk46ekm7kI8CA28lg=,tag:yjQFLR/6RljcgfpRnjQqgw==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:ApA6Y5j67TTtSiClDFVjM/jRcTxX9ixf6AZuYXhbhA==,iv:A1sCliNJ72x1vZZzFruDxPmNPSldow3TaDnYsW/5SCg=,tag:9pqtPdFAjmPvKycleRwZ8Q==,type:str]
#ENC[AES256_GCM,data:1BAAp6TJWo4w47vsPtE=,iv:mMZHNSUPmFYK+jLv7DJ4QqZ+wu4mm+QJJxNKxVSiXaM=,tag:rqg9uSb26e3YmL2KIGhsHg==,type:comment]
DUCKDB_PATH=ENC[AES256_GCM,data:QupAI4arYuggsPTVJ2E9BGt3Yhco,iv:nuXFZUleLIIVf5bXXKuQ174psZZjR8AgwNo677A1Kx0=,tag:+9bn+OTtBCmxZqPt0cYCzg==,type:str]
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:okf9kDEFoPYreiBk1rEoB/6dAFUr,iv:iE+AvfUPX2jxKKBQG9iVMu4LkDcNTxLB9sHY+bmLrso=,tag:GeVRiwbScPrUSsZ1WtfN1g==,type:str]
LANDING_DIR=ENC[AES256_GCM,data:cMnL22EkJGT+jAgM,iv:nItexm3BrtgUVU7TBjHKxBIMuXnuEoAvZcEFFGTmhSI=,tag:E+uXdhTGjKRpjCtRXgcUCA==,type:str]
#ENC[AES256_GCM,data:Cwg5GJ0vOj3LfvtUHVoePytBmvCqJZbqJ5AWhdHcbVFpFVVDYo6GQARMlaLvlEgTe+4mxiR1BgDn6jTokWvynBxgVvQbNLFUINY=,iv:p0fjB0/TQRwdtSxTqFZPtAz9CFRvNjYwxqFLDw9wiko=,tag:EyUfEr0z4PtwvWSo9LbLkA==,type:comment]
REPO_DIR=ENC[AES256_GCM,data:/A==,iv:gTBC1pW2sn/8ZwU2UWErJSecIGmnl5voru0T/klGHuw=,tag:NlFer7SSK9KyAFhrzwv/DQ==,type:str]
WORKFLOWS_PATH=ENC[AES256_GCM,data:lu7JrtxM1TGqY4EErXD13hHMxr4XJg51h2uzXAAFxQ==,iv:j2azHovy7Cu6CREn+cw5hNZkknA5m++lukJskWi7OXU=,tag:d+FSm+hx06SfpD9o7pwr8Q==,type:str]
ALERT_WEBHOOK_URL=
NTFY_TOKEN=
#ENC[AES256_GCM,data:fw1GYCtkn9/1ZOo=,iv:InCb8TtAzDhSrBm6HB34/p/xNJp6o+8rsbPBC7TFaJI=,tag:KDE2ItcWBf9bdfMLFKB96Q==,type:comment]
#ENC[AES256_GCM,data:cz9SOffDAaXZRjw=,iv:7D7YZAyEk5CNlTiL1+KnbPbqfRYMxdtSk40LWErVOfg=,tag:JRHq+gaWSl2h71biklMMwg==,type:comment]
PROXY_URLS=
EXTRACT_WORKERS=ENC[AES256_GCM,data:Yg==,iv:SmgU//42meYyEE4G1Za9uu6bb9IQGeaedEoZAcWLjlk=,tag:rSbiat24itJr6ubCAmazFg==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:LIo=,iv:1p0PrdynUszMLaZUOCecxoWnizbEFiY1foHqxlj65ys=,tag:nellhsGdO8tm6fBmoRhrHQ==,type:str]
#ENC[AES256_GCM,data:+LoWAuRa9mKEV+yMtoGgvYQAMkazF2OGDBd8PRnxND9WpIwVop02YMATBJ50hXLlwSDwzAJPMF7s0+L+rfPl2aH+PUsiuYtm,iv:Z4ZfOmoMGSngSteW4IuEXYc1fn9m9FQXpWnOlBUcq4o=,tag:20G9OZnn35hESJpLG8CfcQ==,type:comment]
EXTRACT_WORKERS=ENC[AES256_GCM,data:sA==,iv:cZ3Ga4VPJfTlKrKeHIXadW7kZI9RqkkriKHAxGT5mqw=,tag:4kTYW7QlCwTkFWHxsl59zQ==,type:str]
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:RV8=,iv:Jrom3R7f1NYbEL+lzypeUmifhGaayY3+uzjRuBxzoxs=,tag:pjYanguedj9RMnDGKiF7Yw==,type:str]
PROXY_URLS_FALLBACK=
CIRCUIT_BREAKER_THRESHOLD=
#ENC[AES256_GCM,data:cPEkskKyxJENcKo0sFupC9R9qq5bQp7sYT9TdQy06FfHPoDQuWw+JuZ5Bs6pOli4NNQBrPX9fdvRwL1TsqmfajHxrSfGgr3l,iv:wF3I5Y8UzYrrJrIeokXn0P2E51eXnqVFdo8lXpKHZGk=,tag:oTPO6+UjI3ZBY5gOf9RLsQ==,type:comment]
GSC_SERVICE_ACCOUNT_PATH=
GSC_SITE_URL=
BING_WEBMASTER_API_KEY=
BING_SITE_URL=
#ENC[AES256_GCM,data:ZXjAEUzOSVtbAC2+M/9kkIx/v4PeyhayuZsk5pkOfhQ=,iv:deQbtHwnXVKLuj+4iTpC+xQr2o4BBxBO+fB7H6GDSbQ=,tag:N1M+2fY8xDKNeEsA3DmuEw==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:n4+hI0zXC0Z2KL0=,iv:xqlQV3G95iUmQpM/RZJ/nHE8npuH6K3MZuPrxDGuUIY=,tag:IqjAnJPRpP0R6XwjbpGqPw==,type:str]
#ENC[AES256_GCM,data:CHMw0ywPH0adeYkcwSndr3JdWyw8f9AdQXQYG62SRCg=,iv:V73eodJud6Z7U8FvuTM52s/2nqNkFz9eneYpPC8OzJM=,tag:obnBC2ERQ19xj8gI8jOosQ==,type:comment]
GEONAMES_USERNAME=ENC[AES256_GCM,data:mLFHQ76lAa2Ygtc=,iv:XWAkwQ075Ph+8qSNmD36PyvPomROENvb3SebVEuVoZg=,tag:cOyW28h6h8nhTbaBZqhmZQ==,type:str]
CENSUS_API_KEY=
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHY2M3c3I0R245ZGxyVHpi\naXNmM1dzMWR5ODFoNFRPTnFnck9USjA3MVNNCllSQkliK1ZPdnRlbGJtdm9IekR4\nZ2RVSXlFTWFGdWQwZEY5ZnpTMVZrT28KLS0tIGxNcVdaS3lHQk5kQjJybXJQbG1X\nN1pxZ0FDTVpzQTBRRmtaSlVqN09leXMKXNQrEW/uPlnZtTZTNs6EAwDrgKVUv0u2\n2NASO7xizGddCqAj2L3bOoqmaF5MpF9FaEUpYj8KAQhRV71wliR72w==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2L2dtZFVnU3BlODk3WXRQ\nOGxSaTBZSXpBc1lpcUkxSHI5dmlyYUo1K0NFClpycmREMTQxNnIyWnpHWVZOWmFx\nUTNRZDFzcFArMVAvckNBbXJMRVBudHcKLS0tIFRhWWQrNkdVTzlucG5nOVJXUVc2\nREFpWHdpV081TFZON1R2ZDlGNHVsWjQKSgmj4hrVEvrIizGmTpgj93ct1a3lUYXl\nBbuPUT8k+Hj5UmP+SoZqNS3kh2a1Nvr17K6e4PjfRAcEfM0UgNA7Jw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
sops_lastmodified=2026-02-24T15:03:02Z
sops_mac=ENC[AES256_GCM,data:d9q9Ak6iYRSnxWwd5wMYf2zCzidNA5cav/MC2Md4eHOkG780dqWod0cqlRxrjAxa96d28a7PoIz0cXhlx5wdvVXGFIGuHg68Y7WCRqzOswPWbd/+KxEL+j0w0xnIi0kh7dohOSj/uswIsnduwcKUQPiDZVsYwji5c8y+DAI8GVI=,iv:QheRtLgWEL7BD42LEW4hwb4gg1X/vnbVuluEU9/Gebc=,tag:Rm1qPNVrfeHv0LJ9qIPK4A==,type:str]
sops_lastmodified=2026-02-24T15:38:25Z
sops_mac=ENC[AES256_GCM,data:eZOqrSiA4f6mYUaYPS6TD6vL2ON1DsLchIjzSE7bcGpZuaTspItlkBNUR6bsiPnhZ+RCv0xfFMvWallLJe4Y/8ftlQCeq2fGLJ30ZlktgrBocXw5ZYUcJz99NjAXf5gvXoq7Bn5DPnX81ju2a2D8YIhGCZ4YzzE+ae0b44MK9zg=,iv:BG/M2ugqhacqm7dEaHmH+v3dTbu7aKojRjsQBSy/8vE=,tag:1zSJgTCzBUKVsahvdriQrg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.12.1

View File

@@ -6,7 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Market Score methodology page** — standalone page at `/{lang}/market-score`
explaining the padelnomics Market Score (Zillow Zestimate-style). Reveals four
input categories (demographics, economic strength, demand evidence, data
completeness) and score band interpretations without exposing weights or
formulas. Full JSON-LD (WebPage + FAQPage + BreadcrumbList), OG tags, and
bilingual content (EN professional, DE Du-form). Added to sitemap and footer.
First "padelnomics Market Score" mention in each article template now links
to the methodology page (hub-and-spoke internal linking).
### Fixed
- **`datetime.utcnow()` deprecation warnings** — replaced all 94 occurrences
across 22 files (source + tests) with `utcnow()` / `utcnow_iso()` helpers
from `core.py`. `utcnow_iso()` produces `YYYY-MM-DD HH:MM:SS` (space
separator) matching SQLite's `datetime('now')` format so lexicographic SQL
comparisons stay correct. `datetime.utcfromtimestamp()` in `seo/_bing.py`
also replaced with `datetime.fromtimestamp(ts, tz=UTC)`. Zero deprecation
warnings remain.
- **Credit ledger ordering** — `get_ledger()` now uses `ORDER BY created_at
DESC, id DESC` to preserve insertion order when multiple credits are added
within the same second.
- **Double language prefix in article URLs** — articles were served at
`/en/en/markets/italy` (double prefix) because `generate_articles()` stored
`url_path` with the lang prefix baked in, but the blueprint is already mounted

View File

@@ -118,6 +118,7 @@
- [x] Cookie consent banner (functional/A/B categories, 1-year cookie)
- [x] Virtual office address on imprint
- [x] SEO/GEO admin hub — GSC + Bing + Umami sync, search/funnel/scorecard views, daily background sync
- [x] Market Score methodology page (`/{lang}/market-score`) — Zillow-style explanation of the padelnomics Market Score; EN + DE; JSON-LD (WebPage + FAQPage + BreadcrumbList); hub-and-spoke internal linking from all article templates
### Testing
- [x] Playwright visual/E2E test suite — 77 tests across 3 files (visual, e2e flows, quote wizard); single session-scoped server + browser; mocked emails + waitlist mode; ~59s runtime

View File

@@ -28,7 +28,7 @@ from pathlib import Path
import niquests
from ._shared import HTTP_TIMEOUT_SECONDS, USER_AGENT, run_extractor, setup_logging
from .proxy import load_proxy_urls, make_round_robin_cycler
from .proxy import load_fallback_proxy_urls, load_proxy_urls, make_tiered_cycler
from .utils import get_last_cursor, landing_path, write_gzip_atomic
logger = setup_logging("padelnomics.extract.playtomic_availability")
@@ -42,6 +42,12 @@ MAX_VENUES_PER_RUN = 20_000
MAX_RETRIES_PER_VENUE = 2
MAX_WORKERS = int(os.environ.get("EXTRACT_WORKERS", "1"))
RECHECK_WINDOW_MINUTES = int(os.environ.get("RECHECK_WINDOW_MINUTES", "90"))
CIRCUIT_BREAKER_THRESHOLD = int(os.environ.get("CIRCUIT_BREAKER_THRESHOLD", "10"))
# Parallel mode submits futures in batches so the circuit breaker can stop
# new submissions after it opens. Already-inflight futures in the current
# batch still complete.
PARALLEL_BATCH_SIZE = 100
# Thread-local storage for per-worker sessions
_thread_local = threading.local()
@@ -169,10 +175,15 @@ def _fetch_venues_parallel(
start_min_str: str,
start_max_str: str,
worker_count: int,
proxy_cycler,
cycler: dict,
fallback_urls: list[str],
) -> tuple[list[dict], int]:
"""Fetch availability for multiple venues in parallel.
Submits futures in batches of PARALLEL_BATCH_SIZE. After each batch
completes, checks the circuit breaker: if it opened and there is no
fallback configured, stops submitting further batches.
Returns (venues_data, venues_errored).
"""
venues_data: list[dict] = []
@@ -181,20 +192,32 @@ def _fetch_venues_parallel(
lock = threading.Lock()
def _worker(tenant_id: str) -> dict | None:
proxy_url = proxy_cycler()
proxy_url = cycler["next_proxy"]()
return _fetch_venue_availability(tenant_id, start_min_str, start_max_str, proxy_url)
with ThreadPoolExecutor(max_workers=worker_count) as pool:
futures = {pool.submit(_worker, tid): tid for tid in tenant_ids}
for batch_start in range(0, len(tenant_ids), PARALLEL_BATCH_SIZE):
# Stop submitting new work if circuit is open with no fallback
if cycler["is_fallback_active"]() and not fallback_urls:
logger.error(
"Circuit open with no fallback — stopping after %d/%d venues",
completed_count, len(tenant_ids),
)
break
for future in as_completed(futures):
batch = tenant_ids[batch_start:batch_start + PARALLEL_BATCH_SIZE]
batch_futures = {pool.submit(_worker, tid): tid for tid in batch}
for future in as_completed(batch_futures):
result = future.result()
with lock:
completed_count += 1
if result is not None:
venues_data.append(result)
cycler["record_success"]()
else:
venues_errored += 1
cycler["record_failure"]()
if completed_count % 500 == 0:
logger.info(
@@ -249,10 +272,11 @@ def extract(
if resume_index > 0:
venues_to_process = venues_to_process[resume_index:]
# Determine parallelism
# Set up tiered proxy cycler with circuit breaker
proxy_urls = load_proxy_urls()
fallback_urls = load_fallback_proxy_urls()
worker_count = min(MAX_WORKERS, len(proxy_urls)) if proxy_urls else 1
proxy_cycler = make_round_robin_cycler(proxy_urls)
cycler = make_tiered_cycler(proxy_urls, fallback_urls, CIRCUIT_BREAKER_THRESHOLD)
start_min_str = start_min.strftime("%Y-%m-%dT%H:%M:%S")
start_max_str = start_max.strftime("%Y-%m-%dT%H:%M:%S")
@@ -260,21 +284,25 @@ def extract(
if worker_count > 1:
logger.info("Parallel mode: %d workers, %d proxies", worker_count, len(proxy_urls))
venues_data, venues_errored = _fetch_venues_parallel(
venues_to_process, start_min_str, start_max_str, worker_count, proxy_cycler,
venues_to_process, start_min_str, start_max_str, worker_count, cycler, fallback_urls,
)
else:
# Serial mode — same as before but uses shared fetch function
logger.info("Serial mode: 1 worker, %d venues", len(venues_to_process))
venues_data = []
venues_errored = 0
for i, tenant_id in enumerate(venues_to_process):
result = _fetch_venue_availability(
tenant_id, start_min_str, start_max_str, proxy_cycler(),
tenant_id, start_min_str, start_max_str, cycler["next_proxy"](),
)
if result is not None:
venues_data.append(result)
cycler["record_success"]()
else:
venues_errored += 1
circuit_opened = cycler["record_failure"]()
if circuit_opened and not fallback_urls:
logger.error("Circuit open with no fallback — writing partial results")
break
if (i + 1) % 100 == 0:
logger.info(
@@ -390,24 +418,30 @@ def extract_recheck(
start_min_str = window_start.strftime("%Y-%m-%dT%H:%M:%S")
start_max_str = window_end.strftime("%Y-%m-%dT%H:%M:%S")
# Determine parallelism
# Set up tiered proxy cycler with circuit breaker
proxy_urls = load_proxy_urls()
fallback_urls = load_fallback_proxy_urls()
worker_count = min(MAX_WORKERS, len(proxy_urls)) if proxy_urls else 1
proxy_cycler = make_round_robin_cycler(proxy_urls)
cycler = make_tiered_cycler(proxy_urls, fallback_urls, CIRCUIT_BREAKER_THRESHOLD)
if worker_count > 1 and len(venues_to_recheck) > 10:
venues_data, venues_errored = _fetch_venues_parallel(
venues_to_recheck, start_min_str, start_max_str, worker_count, proxy_cycler,
venues_to_recheck, start_min_str, start_max_str, worker_count, cycler, fallback_urls,
)
else:
venues_data = []
venues_errored = 0
for tid in venues_to_recheck:
result = _fetch_venue_availability(tid, start_min_str, start_max_str, proxy_cycler())
result = _fetch_venue_availability(tid, start_min_str, start_max_str, cycler["next_proxy"]())
if result is not None:
venues_data.append(result)
cycler["record_success"]()
else:
venues_errored += 1
circuit_opened = cycler["record_failure"]()
if circuit_opened and not fallback_urls:
logger.error("Circuit open with no fallback — writing partial recheck results")
break
# Write recheck file
recheck_hour = now.hour

View File

@@ -6,12 +6,20 @@ When unset, all functions return None/no-op — extractors fall back to direct r
Two routing modes:
round-robin — distribute requests evenly across proxies (default)
sticky — same key always maps to same proxy (for session-tracked sites)
Tiered proxy with circuit breaker:
Primary tier (PROXY_URLS) is used by default — typically cheap datacenter proxies.
Fallback tier (PROXY_URLS_FALLBACK) activates once consecutive failures >= threshold.
Once the circuit opens it stays open for the duration of the run (no auto-recovery).
"""
import itertools
import logging
import os
import threading
logger = logging.getLogger(__name__)
def load_proxy_urls() -> list[str]:
"""Read PROXY_URLS env var (comma-separated). Returns [] if unset.
@@ -23,6 +31,17 @@ def load_proxy_urls() -> list[str]:
return urls
def load_fallback_proxy_urls() -> list[str]:
"""Read PROXY_URLS_FALLBACK env var (comma-separated). Returns [] if unset.
Used as the residential/reliable fallback tier when the primary tier fails.
Format: http://user:pass@host:port or socks5://host:port
"""
raw = os.environ.get("PROXY_URLS_FALLBACK", "")
urls = [u.strip() for u in raw.split(",") if u.strip()]
return urls
def make_round_robin_cycler(proxy_urls: list[str]):
"""Thread-safe round-robin proxy cycler.
@@ -42,6 +61,87 @@ def make_round_robin_cycler(proxy_urls: list[str]):
return next_proxy
def make_tiered_cycler(
primary_urls: list[str],
fallback_urls: list[str],
threshold: int,
) -> dict:
"""Thread-safe tiered proxy cycler with circuit breaker.
Uses primary_urls until consecutive failures >= threshold, then switches
permanently to fallback_urls for the rest of the run. No auto-recovery —
once the circuit opens it stays open to avoid flapping.
Returns a dict of callables:
next_proxy() -> str | None — returns URL from the active tier
record_success() — resets consecutive failure counter
record_failure() -> bool — increments counter; True if circuit just opened
is_fallback_active() -> bool — whether fallback tier is currently active
If primary_urls is empty: always returns from fallback_urls (no circuit breaker needed).
If both are empty: next_proxy() always returns None.
"""
assert threshold > 0, f"threshold must be positive, got {threshold}"
lock = threading.Lock()
state = {
"consecutive_failures": 0,
"fallback_active": False,
}
primary_cycle = itertools.cycle(primary_urls) if primary_urls else None
fallback_cycle = itertools.cycle(fallback_urls) if fallback_urls else None
# No primary proxies — skip circuit breaker, use fallback directly
if not primary_urls:
state["fallback_active"] = True
def next_proxy() -> str | None:
with lock:
if state["fallback_active"]:
return next(fallback_cycle) if fallback_cycle else None
return next(primary_cycle) if primary_cycle else None
def record_success() -> None:
with lock:
state["consecutive_failures"] = 0
def record_failure() -> bool:
"""Increment failure counter. Returns True if circuit just opened."""
with lock:
if state["fallback_active"]:
# Already on fallback — don't trip the circuit again
return False
state["consecutive_failures"] += 1
if state["consecutive_failures"] >= threshold:
state["fallback_active"] = True
if fallback_urls:
logger.warning(
"Circuit open after %d consecutive failures — "
"switching to fallback residential proxies",
state["consecutive_failures"],
)
else:
logger.error(
"Circuit open after %d consecutive failures — "
"no fallback configured, aborting run",
state["consecutive_failures"],
)
return True
return False
def is_fallback_active() -> bool:
with lock:
return state["fallback_active"]
return {
"next_proxy": next_proxy,
"record_success": record_success,
"record_failure": record_failure,
"is_fallback_active": is_fallback_active,
}
def make_sticky_selector(proxy_urls: list[str]):
"""Consistent-hash proxy selector — same key always maps to same proxy.

View File

@@ -2,7 +2,7 @@
Admin domain: role-based admin panel for managing users, tasks, etc.
"""
import json
from datetime import date, datetime, timedelta
from datetime import date, timedelta
from pathlib import Path
import mistune
@@ -29,6 +29,8 @@ from ..core import (
fetch_one,
send_email,
slugify,
utcnow,
utcnow_iso,
)
# Blueprint with its own template folder
@@ -64,9 +66,9 @@ def _admin_context():
async def get_dashboard_stats() -> dict:
"""Get admin dashboard statistics."""
now = datetime.utcnow()
now = utcnow()
today = now.date().isoformat()
week_ago = (now - timedelta(days=7)).isoformat()
week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
users_total = await fetch_one("SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL")
users_today = await fetch_one(
"SELECT COUNT(*) as count FROM users WHERE created_at >= ? AND deleted_at IS NULL",
@@ -211,7 +213,7 @@ async def retry_task(task_id: int) -> bool:
SET status = 'pending', run_at = ?, error = NULL
WHERE id = ? AND status = 'failed'
""",
(datetime.utcnow().isoformat(), task_id)
(utcnow_iso(), task_id)
)
return result > 0
@@ -522,7 +524,7 @@ async def lead_new():
from ..credits import HEAT_CREDIT_COSTS
credit_cost = HEAT_CREDIT_COSTS.get(heat_score, 8)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
verified_at = now if status != "pending_verification" else None
lead_id = await execute(
@@ -567,7 +569,7 @@ async def lead_forward(lead_id: int):
await flash("Already forwarded to this supplier.", "warning")
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
VALUES (?, ?, 0, 'sent', ?)""",
@@ -771,7 +773,7 @@ async def supplier_new():
instagram_url = form.get("instagram_url", "").strip()
youtube_url = form.get("youtube_url", "").strip()
now = datetime.utcnow().isoformat()
now = utcnow_iso()
supplier_id = await execute(
"""INSERT INTO suppliers
(name, slug, country_code, city, region, website, description, category,
@@ -865,7 +867,7 @@ async def flag_toggle():
return redirect(url_for("admin.flags"))
new_enabled = 0 if row["enabled"] else 1
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE feature_flags SET enabled = ?, updated_at = ? WHERE name = ?",
(new_enabled, now, flag_name),
@@ -940,7 +942,7 @@ async def get_email_stats() -> dict:
total = await fetch_one("SELECT COUNT(*) as cnt FROM email_log")
delivered = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'delivered'")
bounced = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE last_event = 'bounced'")
today = datetime.utcnow().date().isoformat()
today = utcnow().date().isoformat()
sent_today = await fetch_one("SELECT COUNT(*) as cnt FROM email_log WHERE created_at >= ?", (today,))
return {
"total": total["cnt"] if total else 0,
@@ -1395,11 +1397,82 @@ SCENARIO_FORM_FIELDS = [
@bp.route("/scenarios")
@role_required("admin")
async def scenarios():
"""List published scenarios."""
"""List published scenarios with optional filters."""
search = request.args.get("search", "").strip()
country_filter = request.args.get("country", "")
venue_filter = request.args.get("venue_type", "")
wheres = ["1=1"]
params: list = []
if search:
wheres.append("(title LIKE ? OR location LIKE ? OR slug LIKE ?)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if country_filter:
wheres.append("country = ?")
params.append(country_filter)
if venue_filter:
wheres.append("venue_type = ?")
params.append(venue_filter)
where = " AND ".join(wheres)
scenario_list = await fetch_all(
"SELECT * FROM published_scenarios ORDER BY created_at DESC"
f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC",
tuple(params),
)
countries = await fetch_all(
"SELECT DISTINCT country FROM published_scenarios WHERE country != '' ORDER BY country"
)
venue_types = await fetch_all(
"SELECT DISTINCT venue_type FROM published_scenarios WHERE venue_type != '' ORDER BY venue_type"
)
total = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios")
return await render_template(
"admin/scenarios.html",
scenarios=scenario_list,
countries=[r["country"] for r in countries],
venue_types=[r["venue_type"] for r in venue_types],
total=total["cnt"] if total else 0,
current_search=search,
current_country=country_filter,
current_venue_type=venue_filter,
is_generating=await _is_generating(),
)
@bp.route("/scenarios/results")
@role_required("admin")
async def scenario_results():
"""HTMX partial for scenario results (used by live polling)."""
search = request.args.get("search", "").strip()
country_filter = request.args.get("country", "")
venue_filter = request.args.get("venue_type", "")
wheres = ["1=1"]
params: list = []
if search:
wheres.append("(title LIKE ? OR location LIKE ? OR slug LIKE ?)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
if country_filter:
wheres.append("country = ?")
params.append(country_filter)
if venue_filter:
wheres.append("venue_type = ?")
params.append(venue_filter)
where = " AND ".join(wheres)
scenario_list = await fetch_all(
f"SELECT * FROM published_scenarios WHERE {where} ORDER BY created_at DESC LIMIT 500",
tuple(params),
)
total = await fetch_one("SELECT COUNT(*) as cnt FROM published_scenarios")
return await render_template(
"admin/partials/scenario_results.html",
scenarios=scenario_list,
total=total["cnt"] if total else 0,
is_generating=await _is_generating(),
)
return await render_template("admin/scenarios.html", scenarios=scenario_list)
@bp.route("/scenarios/new", methods=["GET", "POST"])
@@ -1487,7 +1560,7 @@ async def scenario_edit(scenario_id: int):
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"""UPDATE published_scenarios
@@ -1640,14 +1713,22 @@ async def _get_article_stats() -> dict:
row = await fetch_one(
"""SELECT
COUNT(*) AS total,
SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END) AS live,
SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END) AS scheduled,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft
COALESCE(SUM(CASE WHEN status='published' AND published_at <= datetime('now') THEN 1 ELSE 0 END), 0) AS live,
COALESCE(SUM(CASE WHEN status='published' AND published_at > datetime('now') THEN 1 ELSE 0 END), 0) AS scheduled,
COALESCE(SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END), 0) AS draft
FROM articles"""
)
return dict(row) if row else {"total": 0, "live": 0, "scheduled": 0, "draft": 0}
async def _is_generating() -> bool:
"""Return True if a generate_articles task is currently pending."""
row = await fetch_one(
"SELECT COUNT(*) AS cnt FROM tasks WHERE task_name = 'generate_articles' AND status = 'pending'"
)
return bool(row and row["cnt"] > 0)
@bp.route("/articles")
@role_required("admin")
async def articles():
@@ -1677,6 +1758,7 @@ async def articles():
current_template=template_filter,
current_language=language_filter,
page=page,
is_generating=await _is_generating(),
)
@@ -1695,7 +1777,10 @@ async def article_results():
language=language_filter or None, search=search or None, page=page,
)
return await render_template(
"admin/partials/article_results.html", articles=article_list, page=page,
"admin/partials/article_results.html",
articles=article_list,
page=page,
is_generating=await _is_generating(),
)
@@ -1740,7 +1825,7 @@ async def article_new():
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body)
pub_dt = published_at or datetime.utcnow().isoformat()
pub_dt = published_at or utcnow_iso()
await execute(
"""INSERT INTO articles
@@ -1800,7 +1885,7 @@ async def article_edit(article_id: int):
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article['slug']}.md").write_text(body)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
pub_dt = published_at or article["published_at"]
await execute(
@@ -1867,7 +1952,7 @@ async def article_publish(article_id: int):
return redirect(url_for("admin.articles"))
new_status = "published" if article["status"] == "draft" else "draft"
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
(new_status, now, article_id),

View File

@@ -18,7 +18,7 @@
<a href="{{ url_for('admin.article_new') }}" class="btn btn-sm">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Rebuild all articles? This will re-render every article from its template.', this.closest('form'))">Rebuild All</button>
</form>
</div>
</header>

View File

@@ -30,7 +30,7 @@
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="contact_id" value="{{ c.id if c.id is defined else (c.get('id', '') if c is mapping else '') }}">
<button type="submit" class="btn-outline btn-sm" style="color:#DC2626" onclick="return confirm('Remove this contact?')">Remove</button>
<button type="button" class="btn-outline btn-sm" style="color:#DC2626" onclick="confirmAction('Remove this contact from the audience?', this.closest('form'))">Remove</button>
</form>
</td>
</tr>

View File

@@ -27,6 +27,15 @@
.admin-main { flex: 1; padding: 2rem; overflow-y: auto; }
#confirm-dialog {
border: none; border-radius: 12px; padding: 1.5rem; max-width: 380px; width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.08);
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0;
}
#confirm-dialog::backdrop { background: rgba(15,23,42,0.45); backdrop-filter: blur(3px); }
#confirm-dialog p { margin: 0 0 1.25rem; font-size: 0.9375rem; color: #0F172A; line-height: 1.55; }
#confirm-dialog .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
@media (max-width: 768px) {
.admin-layout { flex-direction: column; }
.admin-sidebar {
@@ -130,4 +139,24 @@
{% block admin_content %}{% endblock %}
</main>
</div>
<dialog id="confirm-dialog">
<p id="confirm-msg"></p>
<div class="dialog-actions">
<button id="confirm-cancel" class="btn-outline btn-sm">Cancel</button>
<button id="confirm-ok" class="btn btn-sm">Confirm</button>
</div>
</dialog>
<script>
function confirmAction(message, form) {
var dialog = document.getElementById('confirm-dialog');
document.getElementById('confirm-msg').textContent = message;
var ok = document.getElementById('confirm-ok');
var newOk = ok.cloneNode(true);
ok.replaceWith(newOk);
newOk.addEventListener('click', function() { dialog.close(); form.submit(); });
document.getElementById('confirm-cancel').addEventListener('click', function() { dialog.close(); }, { once: true });
dialog.showModal();
}
</script>
{% endblock %}

View File

@@ -45,7 +45,7 @@
</p>
</div>
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate articles? Existing articles will be updated.')">
<button type="button" class="btn" style="width: 100%;" onclick="confirmAction('Generate articles? Existing articles will be updated in-place.', this.closest('form'))">
Generate Articles
</button>
</form>

View File

@@ -1,3 +1,10 @@
{% if is_generating %}
<div hx-get="{{ url_for('admin.article_results') }}"
hx-trigger="every 3s"
hx-target="#article-results"
hx-swap="innerHTML"
style="display:none" aria-hidden="true"></div>
{% endif %}
{% if articles %}
<div class="card">
<table class="table text-sm">

View File

@@ -0,0 +1,44 @@
{% if is_generating %}
<div hx-get="{{ url_for('admin.scenario_results') }}"
hx-trigger="every 3s"
hx-target="#scenario-results"
hx-swap="innerHTML"
style="display:none" aria-hidden="true"></div>
{% endif %}
{% if scenarios %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn-outline btn-sm" onclick="confirmAction('Delete this scenario? This cannot be undone.', this.closest('form'))">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No scenarios match the current filters.</p>
{% endif %}

View File

@@ -1,57 +1,58 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "scenarios" %}
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
{% block title %}Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-8">
<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl">Published Scenarios</h1>
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
<h1 class="text-2xl">Scenarios</h1>
<p class="text-slate text-sm">
Pre-computed calculator outputs — embedded as cards in articles and PDFs.
Showing {{ scenarios | length }} of {{ total }}.
</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div>
</header>
<div class="card">
{% if scenarios %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='en') }}" class="btn-outline btn-sm">PDF EN</a>
<a href="{{ url_for('admin.scenario_pdf', scenario_id=s.id, lang='de') }}" class="btn-outline btn-sm">PDF DE</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this scenario?')">Delete</button>
</form>
</td>
</tr>
<form method="get" class="card mb-4 flex flex-wrap gap-3 items-end">
<div class="flex-1 min-w-48">
<label class="block text-sm text-slate mb-1">Search</label>
<input type="text" name="search" value="{{ current_search }}"
placeholder="Title, location, slug…"
class="input w-full">
</div>
<div>
<label class="block text-sm text-slate mb-1">Country</label>
<select name="country" class="input">
<option value="">All countries</option>
{% for c in countries %}
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No published scenarios yet.</p>
</select>
</div>
<div>
<label class="block text-sm text-slate mb-1">Venue type</label>
<select name="venue_type" class="input">
<option value="">All types</option>
{% for v in venue_types %}
<option value="{{ v }}" {% if v == current_venue_type %}selected{% endif %}>{{ v | capitalize }}</option>
{% endfor %}
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="btn">Filter</button>
{% if current_search or current_country or current_venue_type %}
<a href="{{ url_for('admin.scenarios') }}" class="btn-outline">Clear</a>
{% endif %}
</div>
</form>
<div class="card">
<div id="scenario-results">
{% include "admin/partials/scenario_results.html" %}
</div>
</div>
{% endblock %}

View File

@@ -15,7 +15,7 @@
<a href="{{ url_for('admin.template_generate', slug=config_data.slug) }}" class="btn">Generate Articles</a>
<form method="post" action="{{ url_for('admin.template_regenerate', slug=config_data.slug) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Regenerate all articles for this template with fresh data?')">
<button type="button" class="btn-outline" onclick="confirmAction('Regenerate all articles for this template with fresh data? Existing articles will be overwritten.', this.closest('form'))">
Regenerate
</button>
</form>

View File

@@ -7,8 +7,10 @@ from pathlib import Path
from quart import Quart, Response, abort, g, redirect, request, session, url_for
from .analytics import close_analytics_db, open_analytics_db
from .core import close_db, config, get_csrf_token, init_db, is_flag_enabled, setup_request_id
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
from .core import close_db, config, get_csrf_token, init_db, is_flag_enabled, setup_logging, setup_request_id
setup_logging()
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_country_name, get_translations
_ASSET_VERSION = str(int(time.time()))
@@ -94,6 +96,7 @@ def create_app() -> Quart:
app.jinja_env.filters["fmt_x"] = _fmt_x
app.jinja_env.filters["fmt_n"] = _fmt_n
app.jinja_env.filters["tformat"] = _tformat # translate with placeholders: {{ t.key | tformat(count=n) }}
app.jinja_env.filters["country_name"] = get_country_name # {{ article.country | country_name(lang) }}
# Session config
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
@@ -208,7 +211,7 @@ def create_app() -> Quart:
@app.context_processor
def inject_globals():
from datetime import datetime
from .core import utcnow as _utcnow
lang = g.get("lang") or _detect_lang()
g.lang = lang # ensure g.lang is always set (e.g. for dashboard/billing routes)
effective_lang = lang if lang in SUPPORTED_LANGS else "en"
@@ -217,7 +220,7 @@ def create_app() -> Quart:
"user": g.get("user"),
"subscription": g.get("subscription"),
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
"now": datetime.utcnow(),
"now": _utcnow(),
"csrf_token": get_csrf_token,
"ab_variant": getattr(g, "ab_variant", None),
"ab_tag": getattr(g, "ab_tag", None),
@@ -292,6 +295,10 @@ def create_app() -> Quart:
async def legacy_suppliers():
return redirect("/en/suppliers", 301)
@app.route("/market-score")
async def legacy_market_score():
return redirect("/en/market-score", 301)
# -------------------------------------------------------------------------
# Blueprint registration
# -------------------------------------------------------------------------

View File

@@ -3,7 +3,7 @@ Auth domain: magic link authentication, user management, decorators.
"""
import secrets
from datetime import datetime, timedelta
from datetime import timedelta
from functools import wraps
from pathlib import Path
@@ -18,6 +18,8 @@ from ..core import (
fetch_one,
is_disposable_email,
is_flag_enabled,
utcnow,
utcnow_iso,
)
from ..i18n import SUPPORTED_LANGS, get_translations
@@ -64,7 +66,7 @@ async def get_user_by_email(email: str) -> dict | None:
async def create_user(email: str) -> int:
"""Create new user, return ID."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now)
)
@@ -82,10 +84,10 @@ async def update_user(user_id: int, **fields) -> None:
async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int:
"""Create auth token for user."""
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
expires = datetime.utcnow() + timedelta(minutes=minutes)
expires = utcnow() + timedelta(minutes=minutes)
return await execute(
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, token, expires.isoformat()),
(user_id, token, expires.strftime("%Y-%m-%d %H:%M:%S")),
)
@@ -98,14 +100,14 @@ async def get_valid_token(token: str) -> dict | None:
JOIN users u ON u.id = at.user_id
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
""",
(token, datetime.utcnow().isoformat()),
(token, utcnow_iso()),
)
async def mark_token_used(token_id: int) -> None:
"""Mark token as used."""
await execute(
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id)
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", (utcnow_iso(), token_id)
)
@@ -331,7 +333,7 @@ async def verify():
await mark_token_used(token_data["id"])
# Update last login
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
await update_user(token_data["user_id"], last_login_at=utcnow_iso())
# Set session
session.permanent = True

View File

@@ -5,7 +5,7 @@ Payment provider: paddle
import json
import secrets
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from paddle_billing import Client as PaddleClient
@@ -14,7 +14,7 @@ from paddle_billing.Notifications import Secret, Verifier
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
from ..auth.routes import login_required
from ..core import config, execute, fetch_one, get_paddle_price
from ..core import config, execute, fetch_one, get_paddle_price, utcnow, utcnow_iso
from ..i18n import get_translations
@@ -69,7 +69,7 @@ async def upsert_subscription(
current_period_end: str = None,
) -> int:
"""Create or update subscription. Finds existing by provider_subscription_id."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
existing = await fetch_one(
"SELECT id FROM subscriptions WHERE provider_subscription_id = ?",
@@ -104,7 +104,7 @@ async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
"""Update subscription status by provider subscription ID."""
extra["updated_at"] = datetime.utcnow().isoformat()
extra["updated_at"] = utcnow_iso()
extra["status"] = status
sets = ", ".join(f"{k} = ?" for k in extra)
values = list(extra.values())
@@ -343,7 +343,7 @@ async def _handle_supplier_subscription_activated(data: dict, custom_data: dict)
base_plan, tier = _derive_tier_from_plan(plan)
monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db_transaction() as db:
# Update supplier record — Basic tier also gets is_verified = 1
@@ -392,7 +392,7 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
supplier_id = custom_data.get("supplier_id")
user_id = custom_data.get("user_id")
now = datetime.utcnow().isoformat()
now = utcnow_iso()
items = data.get("items", [])
for item in items:
@@ -412,10 +412,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
# Sticky boost purchases
elif key == "boost_sticky_week" and supplier_id:
from datetime import timedelta
from ..core import transaction as db_transaction
expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat()
expires = (utcnow() + timedelta(weeks=1)).strftime("%Y-%m-%d %H:%M:%S")
country = custom_data.get("sticky_country", "")
async with db_transaction() as db:
await db.execute(
@@ -430,10 +428,8 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
)
elif key == "boost_sticky_month" and supplier_id:
from datetime import timedelta
from ..core import transaction as db_transaction
expires = (datetime.utcnow() + timedelta(days=30)).isoformat()
expires = (utcnow() + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
country = custom_data.get("sticky_country", "")
async with db_transaction() as db:
await db.execute(

View File

@@ -6,6 +6,7 @@ Data comes from DuckDB serving tables. Only articles + published_scenarios
are stored in SQLite (routing / application state).
"""
import json
import logging
import re
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
@@ -15,7 +16,9 @@ import yaml
from jinja2 import ChainableUndefined, Environment
from ..analytics import fetch_analytics
from ..core import execute, fetch_one, slugify
from ..core import execute, fetch_one, slugify, transaction, utcnow_iso
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
@@ -135,7 +138,7 @@ def _validate_table_name(data_table: str) -> None:
def _datetimeformat(value: str, fmt: str = "%Y-%m-%d") -> str:
"""Jinja2 filter: format a date string (or 'now') with strftime."""
from datetime import UTC, datetime
from datetime import datetime
if value == "now":
dt = datetime.now(UTC)
@@ -301,8 +304,9 @@ async def generate_articles(
publish_date = start_date
published_today = 0
generated = 0
now_iso = datetime.now(UTC).isoformat()
now_iso = utcnow_iso()
async with transaction() as db:
for row in rows:
for lang in config["languages"]:
# Build render context: row data + language
@@ -338,24 +342,15 @@ async def generate_articles(
city = row.get("city_name", row.get("city", ""))
country = row.get("country", state.get("country", ""))
# Upsert published scenario
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?",
(scenario_slug,),
)
if existing:
await execute(
"""UPDATE published_scenarios
SET state_json = ?, calc_json = ?, updated_at = ?
WHERE slug = ?""",
(json.dumps(state), json.dumps(d), now_iso, scenario_slug),
)
else:
await execute(
await db.execute(
"""INSERT INTO published_scenarios
(slug, title, location, country, venue_type, ownership,
court_config, state_json, calc_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
state_json = excluded.state_json,
calc_json = excluded.calc_json,
updated_at = excluded.created_at""",
(
scenario_slug, city, city, country,
state.get("venue", "indoor"),
@@ -434,28 +429,20 @@ async def generate_articles(
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body_md)
# Upsert article in SQLite — keyed by (url_path, language) since
# multiple languages share the same url_path
existing_article = await fetch_one(
"SELECT id FROM articles WHERE url_path = ? AND language = ?",
(url_path, lang),
)
if existing_article:
await execute(
"""UPDATE articles
SET title = ?, meta_description = ?, template_slug = ?,
language = ?, date_modified = ?, updated_at = ?,
seo_head = ?
WHERE url_path = ? AND language = ?""",
(title, meta_desc, slug, lang, now_iso, now_iso, seo_head, url_path, lang),
)
else:
await execute(
# Upsert article in SQLite — keyed by (url_path, language)
await db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified,
seo_head, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)
ON CONFLICT(url_path, language) DO UPDATE SET
title = excluded.title,
meta_description = excluded.meta_description,
template_slug = excluded.template_slug,
date_modified = excluded.date_modified,
seo_head = excluded.seo_head,
updated_at = excluded.date_modified""",
(
url_path, article_slug, title, meta_desc,
row.get("country", ""), row.get("region", ""),
@@ -464,6 +451,8 @@ async def generate_articles(
)
generated += 1
if generated % 25 == 0:
logger.info("%s: %d articles written…", slug, generated)
# Stagger dates
published_today += 1
@@ -471,6 +460,7 @@ async def generate_articles(
published_today = 0
publish_date += timedelta(days=1)
logger.info("%s: done — %d total", slug, generated)
return generated

View File

@@ -20,8 +20,8 @@ priority_column: population
<div class="stats-strip__value">{{ padel_venue_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Market Score</div>
<div class="stats-strip__value">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if market_score >= 65 %}#16A34A{% elif market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Spitzenpreis</div>
@@ -33,7 +33,7 @@ priority_column: population
</div>
</div>
{{ city_name }} erreicht einen **<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 70 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 45 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.
{{ city_name }} erreicht einen **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 70 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 45 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.
Die entscheidende Frage für Investoren: Was bringt ein Padel-Investment bei den aktuellen Preisen, Auslastungsraten und Baukosten tatsächlich? Das Finanzmodell unten rechnet mit echten Marktdaten aus {{ city_name }}.
@@ -92,23 +92,41 @@ Eine detaillierte Preisanalyse mit Preisspannen und Vergleichsdaten findest Du a
## FAQ
**Ist {{ city_name }} ein guter Standort für eine Padelhalle?**
<details>
<summary>Ist {{ city_name }} ein guter Standort für eine Padelhalle?</summary>
{{ city_name }} erreicht **{{ market_score | round(1) }}/100** auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score, der Bevölkerungsgröße, Anlagendichte und Datenqualität berücksichtigt. {% if market_score >= 70 %}Ein Score über 70 signalisiert einen starken Markt: große Bevölkerung, wachsende Anlagenzahl und belastbare Preisdaten. {% elif market_score >= 45 %}Ein mittlerer Score bedeutet solide Grundlagen, aber einen teils stärker umkämpften oder datenlimitierten Markt. {% else %}Ein niedrigerer Score spricht für eine kleinere Stadt, begrenzte Datenlage oder einen Markt im Aufbau — was gleichzeitig weniger Wettbewerb und First-Mover-Vorteile bedeuten kann. {% endif %}Mit dem [Finanzplaner](/{{ language }}/planner) kannst Du Deine eigenen Annahmen durchrechnen.
</details>
<details>
<summary>Wie hoch ist die Rendite einer Padelhalle in {{ city_name }}?</summary>
**Wie hoch ist die Rendite einer Padelhalle in {{ city_name }}?**
Die Rendite hängt von Deinen Baukosten, der Courtanzahl, Preisgestaltung und Auslastungsannahmen ab. Das Finanzmodell oben nutzt echte Marktdaten aus {{ city_name }} als Ausgangswerte — Spitzenpreis {% if median_peak_rate %}{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std{% else %}geschätzt auf Basis regionaler Benchmarks{% endif %}, geschätzte Auslastung {% if median_occupancy_rate %}{{ (median_occupancy_rate * 100) | round(0) | int }}%{% else %}basierend auf Landesdurchschnitt{% endif %}. [Passe die Eingaben im Planer an](/{{ language }}/planner), um Dein Szenario zu vergleichen.
</details>
<details>
<summary>Was kostet es, eine Padelhalle in {{ city_name }} zu bauen?</summary>
**Was kostet es, eine Padelhalle in {{ city_name }} zu bauen?**
Das Gesamtinvestment hängt vom Hallentyp (Indoor vs. Outdoor), Grundstückskosten und lokalen Baustandards in {{ country_name_en }} ab. Das CAPEX-Modell oben schlüsselt die wichtigsten Kostentreiber für einen typischen Bau in {{ city_name }} auf.
</details>
<details>
<summary>Wie viele Padelplätze gibt es in {{ city_name }}?</summary>
**Wie viele Padelplätze gibt es in {{ city_name }}?**
{{ city_name }} hat **{{ padel_venue_count }} Padelanlagen**. Bei {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohnern entspricht das **{{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner**.
</details>
<details>
<summary>Was kosten Padel-Courts in {{ city_name }}?</summary>
**Was kosten Padel-Courts in {{ city_name }}?**
{% if median_peak_rate %}Zu Hauptzeiten liegen die Preise bei durchschnittlich **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std**, in Nebenzeiten bei ca. **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**. Die Daten stammen aus Live-Buchungsdaten von Playtomic.{% else %}Für {{ city_name }} liegen noch keine Playtomic-Preisdaten vor. Das Finanzmodell nutzt Benchmarks aus {{ country_name_en }} als Näherung.{% endif %}
</details>
<details>
<summary>Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
**Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?**
Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score von {{ market_score | round(1) }}/100 zeigt {{ city_name }}s Position unter den erfassten Städten in {{ country_name_en }}. In der [Marktübersicht für {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Bereit für Deine eigene Kalkulation? →
@@ -127,8 +145,8 @@ Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;co
<div class="stats-strip__value">{{ padel_venue_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Market Score</div>
<div class="stats-strip__value">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if market_score >= 65 %}#16A34A{% elif market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Peak Rate</div>
@@ -140,7 +158,7 @@ Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;co
</div>
</div>
{{ city_name }} has a **<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 70 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 45 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
{{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 70 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 45 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.
The question investors actually need answered is: given current pricing, occupancy, and build costs, what does the return look like? The financial model below uses real {{ city_name }} market data to give you that answer.
@@ -199,23 +217,41 @@ For a detailed pricing breakdown with price ranges and venue comparisons, see th
## FAQ
**Is {{ city_name }} a good location for a padel center?**
<details>
<summary>Is {{ city_name }} a good location for a padel center?</summary>
{{ city_name }} scores **{{ market_score | round(1) }}/100** on the <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score, which accounts for population size, existing venue density, and data completeness. {% if market_score >= 70 %}A score above 70 indicates a strong market: high population, growing venue count, and solid pricing data. {% elif market_score >= 45 %}A mid-range score means decent fundamentals but a more competitive or data-limited market. {% else %}A lower score reflects either a smaller city, sparse venue data, or an early-stage market — which can also mean lower competition and first-mover advantage. {% endif %}Use the [Padelnomics planner](/{{ language }}/planner) to model your specific assumptions.
</details>
<details>
<summary>What is the return on investment for a padel center in {{ city_name }}?</summary>
**What is the return on investment for a padel center in {{ city_name }}?**
ROI depends on your build cost, court count, pricing, and occupancy assumptions. The financial model above uses real {{ city_name }} market data as defaults — peak rate {% if median_peak_rate %}{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr{% else %}estimated from regional benchmarks{% endif %}, estimated occupancy {% if median_occupancy_rate %}{{ (median_occupancy_rate * 100) | round(0) | int }}%{% else %}based on country averages{% endif %}. [Adjust the inputs in the planner](/{{ language }}/planner) to see how your scenario compares.
</details>
<details>
<summary>How much does it cost to build a padel center in {{ city_name }}?</summary>
**How much does it cost to build a padel center in {{ city_name }}?**
Total investment depends on venue type (indoor vs outdoor), land costs, and local construction standards in {{ country_name_en }}. The capex model above breaks down the key cost drivers for a typical {{ city_name }} build based on current market assumptions.
</details>
<details>
<summary>How many padel courts are there in {{ city_name }}?</summary>
**How many padel courts are there in {{ city_name }}?**
{{ city_name }} has **{{ padel_venue_count }} padel venues**. With a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %}, that translates to **{{ venues_per_100k | round(1) }} venues per 100,000 residents**.
</details>
<details>
<summary>What are typical padel court rental prices in {{ city_name }}?</summary>
**What are typical padel court rental prices in {{ city_name }}?**
{% if median_peak_rate %}Peak hour rates average around **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr**, while off-peak rates are approximately **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr**. These figures come from live Playtomic booking data.{% else %}Pricing data from Playtomic is not yet available for {{ city_name }}. The financial model uses {{ country_name_en }}-wide benchmarks as a proxy.{% endif %}
</details>
<details>
<summary>How does {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
**How does {{ city_name }} compare to other {{ country_name_en }} cities?**
{{ city_name }}'s <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ market_score | round(1) }}/100 reflects its ranking among tracked {{ country_name_en }} cities. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full comparison across cities.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Ready to run the numbers for {{ city_name }}? →

View File

@@ -55,7 +55,7 @@ Die Preisspanne von {{ hourly_rate_p25 | round(0) | int }} bis {{ hourly_rate_p7
## Wie steht {{ city_name }} im Vergleich da?
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 65 %}Mit einem Market Score von {{ market_score | round(1) }}/100 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 40 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %}
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 65 %}Mit einem <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> von {{ market_score | round(1) }}/100 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 40 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %}
Die Anlagendichte von {{ venues_per_100k | round(1) }} pro 100K Einwohner beeinflusst die Preisgestaltung direkt: {% if venues_per_100k >= 3.0 %}Höhere Dichte bedeutet mehr Wettbewerb, was die Preise eher stabilisiert oder senkt.{% elif venues_per_100k >= 1.0 %}Moderate Dichte ermöglicht marktgerechte Preise bei gleichzeitigem Wachstumsspielraum.{% else %}Niedrige Dichte gibt Betreibern mehr Preissetzungsmacht — vorausgesetzt, die Nachfrage ist da.{% endif %}
@@ -86,20 +86,35 @@ Diese Preisdaten fließen direkt in das Finanzmodell für {{ city_name }} ein:
## FAQ
**Was kostet eine Padel-Stunde in {{ city_name }}?**
<details>
<summary>Was kostet eine Padel-Stunde in {{ city_name }}?</summary>
Der mediane Stundenpreis in {{ city_name }} liegt bei **{{ median_hourly_rate | round(0) | int }} {{ price_currency }}/Std** — zu Hauptzeiten **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std**, in Nebenzeiten **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**. Die günstigsten Anlagen starten bei {{ hourly_rate_p25 | round(0) | int }} {{ price_currency }}/Std, Premium-Anlagen verlangen {{ hourly_rate_p75 | round(0) | int }} {{ price_currency }}/Std oder mehr. Datenbasis: {{ venue_count }} aktive Playtomic-Anlagen.
</details>
<details>
<summary>Wann ist Padel in {{ city_name }} am günstigsten?</summary>
**Wann ist Padel in {{ city_name }} am günstigsten?**
In Nebenzeiten — typischerweise vormittags und am frühen Nachmittag unter der Woche — liegen die Preise bei ca. **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/Std**, verglichen mit **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/Std** zu Hauptzeiten.
</details>
<details>
<summary>Wie viele Padelanlagen gibt es in {{ city_name }}?</summary>
**Wie viele Padelanlagen gibt es in {{ city_name }}?**
{{ city_name }} hat **{{ padel_venue_count }} Padelanlagen** insgesamt. Diese Preisanalyse erfasst **{{ venue_count }} Anlagen** mit ausreichend Playtomic-Buchungsdaten.
</details>
<details>
<summary>Wie schneidet {{ city_name }} preislich im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
**Wie schneidet {{ city_name }} preislich im Vergleich zu anderen Städten in {{ country_name_en }} ab?**
Die Preise in {{ city_name }} liegen {% if median_peak_rate >= 40 %}im oberen Bereich{% elif median_peak_rate >= 25 %}im Mittelfeld{% else %}unter dem Durchschnitt{% endif %} für {{ country_name_en }}. In der [Marktübersicht {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
</details>
<details>
<summary>Steigen oder fallen die Padel-Preise in {{ city_name }}?</summary>
**Steigen oder fallen die Padel-Preise in {{ city_name }}?**
Die aktuellen Daten sind eine Momentaufnahme auf Basis von Playtomic-Livedaten. Generell stabilisieren sich Preise in reiferen Märkten, während sie in wachsenden Märkten tendenziell steigen. Im [Finanzplaner](/{{ language }}/planner) kannst Du verschiedene Preisszenarien durchrechnen.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Nutze die {{ city_name }}-Preisdaten für Deinen Businessplan →
@@ -153,7 +168,7 @@ The P25P75 price range of {{ hourly_rate_p25 | round(0) | int }} to {{ hourly
## How Does {{ city_name }} Compare?
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 65 %}With a market score of {{ market_score | round(1) }}/100, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 40 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %}
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 65 %}With a <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 40 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %}
Venue density of {{ venues_per_100k | round(1) }} per 100K residents directly influences pricing: {% if venues_per_100k >= 3.0 %}higher density means more competition, which tends to stabilize or compress prices.{% elif venues_per_100k >= 1.0 %}moderate density supports market-rate pricing with room for growth.{% else %}low density gives operators more pricing power — provided demand exists.{% endif %}
@@ -184,20 +199,35 @@ These pricing numbers feed directly into the financial model for {{ city_name }}
## FAQ
**How much does it cost to rent a padel court in {{ city_name }}?**
<details>
<summary>How much does it cost to rent a padel court in {{ city_name }}?</summary>
The median padel court rental rate in {{ city_name }} is **{{ median_hourly_rate | round(0) | int }} {{ price_currency }}/hr** overall — **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr** at peak times and **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr** off-peak. The cheapest venues charge from {{ hourly_rate_p25 | round(0) | int }} {{ price_currency }}/hr; premium venues reach {{ hourly_rate_p75 | round(0) | int }} {{ price_currency }}/hr or more. Data comes from {{ venue_count }} active Playtomic venues.
</details>
<details>
<summary>When are padel courts cheapest in {{ city_name }}?</summary>
**When are padel courts cheapest in {{ city_name }}?**
Off-peak slots — typically weekday mornings and early afternoons — are priced at around **{{ median_offpeak_rate | round(0) | int }} {{ price_currency }}/hr**, compared to **{{ median_peak_rate | round(0) | int }} {{ price_currency }}/hr** during peak hours.
</details>
<details>
<summary>How many padel venues are there in {{ city_name }}?</summary>
**How many padel venues are there in {{ city_name }}?**
{{ city_name }} has **{{ padel_venue_count }} padel venues** in total. This pricing analysis covers **{{ venue_count }} venues** with sufficient Playtomic booking data.
</details>
<details>
<summary>How does padel pricing in {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
**How does padel pricing in {{ city_name }} compare to other {{ country_name_en }} cities?**
{{ city_name }}'s pricing sits {% if median_peak_rate >= 40 %}at the higher end{% elif median_peak_rate >= 25 %}in the mid-range{% else %}below average{% endif %} for {{ country_name_en }}. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full city-by-city comparison.
</details>
<details>
<summary>Are padel court prices in {{ city_name }} going up or down?</summary>
**Are padel court prices in {{ city_name }} going up or down?**
The current data is a snapshot based on live Playtomic booking data. In general, prices stabilise in mature markets and tend to rise in growing ones. Use the [financial planner](/{{ language }}/planner) to model different pricing scenarios and stress-test your business plan.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Use {{ city_name }} pricing data in your business plan →

View File

@@ -25,8 +25,8 @@ priority_column: total_venues
<div class="stats-strip__value">{{ city_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Ø Market Score</div>
<div class="stats-strip__value">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if avg_market_score >= 65 %}#16A34A{% elif avg_market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Median Spitzenpreis</div>
@@ -34,7 +34,7 @@ priority_column: total_venues
</div>
</div>
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 40 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}.
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 40 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}.
## Marktlandschaft
@@ -82,20 +82,35 @@ Jede Stadt hat andere Kostenstrukturen, Wettbewerbsbedingungen und Zielgruppen.
## FAQ
**Wie viele Padel-Courts gibt es in {{ country_name_en }}?**
<details>
<summary>Wie viele Padel-Courts gibt es in {{ country_name_en }}?</summary>
Wir erfassen aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten** in {{ country_name_en }}. Unsere Daten stammen von Playtomic und aus Overpass/OpenStreetMap. Die tatsächliche Zahl liegt vermutlich höher, da unabhängige Clubs ohne Buchungsplattform nicht immer erfasst werden.
</details>
<details>
<summary>Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?</summary>
**Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?**
Unsere Spitzenstadt nach <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score ist **{{ top_city_names[0] }}** (Score: {{ top_city_market_score }}/100). Der Score kombiniert Bevölkerungsgröße, Anlagendichte und Datenqualität. Eine hohe Punktzahl deutet auf einen großen adressierbaren Markt mit validierten Preisdaten hin. Die beste Stadt für *Dein* Vorhaben hängt aber von Faktoren wie Flächenverfügbarkeit, lokalem Wettbewerb und Deiner Zielgruppe ab. Nutze den <a href="/{{ language }}/planner">Finanzplaner</a>, um verschiedene Standorte durchzurechnen.
</details>
<details>
<summary>Was kostet eine Stunde Padel in {{ country_name_en }}?</summary>
**Was kostet eine Stunde Padel in {{ country_name_en }}?**
{% if median_peak_rate %}Laut Live-Daten von Playtomic liegt der Median-Hauptzeitpreis in {{ country_name_en }} bei **{{ median_peak_rate | int }} {{ price_currency }}/Std**, Nebenzeit bei **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/Std**. Die Preise variieren stark zwischen Städten — auf den jeweiligen Stadtseiten findest Du lokale Benchmarks.{% else %}Aggregierte Preisdaten sind für {{ country_name_en }} noch nicht verfügbar. Prüfe die einzelnen Stadtseiten für lokale Daten.{% endif %}
</details>
<details>
<summary>Wie schnell wächst Padel in {{ country_name_en }}?</summary>
**Wie schnell wächst Padel in {{ country_name_en }}?**
Padel gehört zu den am schnellsten wachsenden Racketsportarten in Europa. Mit {{ total_venues }} erfassten Anlagen in {{ city_count }} Städten zeigt {{ country_name_en }} {% if avg_market_score >= 65 %}bereits eine reife Infrastruktur — Wachstum kommt hier vor allem aus steigender Spielfrequenz und Premiumangeboten{% elif avg_market_score >= 40 %}eine klare Wachstumsdynamik mit steigender Nachfrage und neuen Anlagen{% else %}ein frühes Wachstumsstadium mit großem Potenzial für Neueintritte{% endif %}. Die Sportart profitiert von niedriger Einstiegshürde, hohem Spaßfaktor und starker Mund-zu-Mund-Verbreitung.
</details>
<details>
<summary>Welche Städte haben die besten Preisdaten?</summary>
**Welche Städte haben die besten Preisdaten?**
Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score (wie {{ top_city_names[0] }}) haben in der Regel die umfassendsten Preisdaten, weil dort mehr Anlagen auf Playtomic gelistet sind. In unserem <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }}-Marktüberblick</a> findest Du alle Städte nach Market Score sortiert.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Du überlegst, eine Padelhalle in {{ country_name_en }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
@@ -115,8 +130,8 @@ Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;f
<div class="stats-strip__value">{{ city_count }}</div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Avg Market Score</div>
<div class="stats-strip__value">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
<div class="stats-strip__value" style="color:{% if avg_market_score >= 65 %}#16A34A{% elif avg_market_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
</div>
<div class="stats-strip__item">
<div class="stats-strip__label">Median Peak Rate</div>
@@ -124,7 +139,7 @@ Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;f
</div>
</div>
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 40 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}.
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 65 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 40 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}.
## Market Landscape
@@ -172,20 +187,35 @@ Every city has a different cost structure, competitive landscape, and customer b
## FAQ
**How many padel courts are there in {{ country_name_en }}?**
<details>
<summary>How many padel courts are there in {{ country_name_en }}?</summary>
We currently track **{{ total_venues }} padel venues** across **{{ city_count }} cities** in {{ country_name_en }}. This covers venues listed on Playtomic and venues identified through our Overpass/OpenStreetMap data source. The actual total may be higher as independent clubs not listed on booking platforms are not always captured.
</details>
<details>
<summary>Which city in {{ country_name_en }} is best for a padel center?</summary>
**Which city in {{ country_name_en }} is best for a padel center?**
Our top-ranked city by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score is **{{ top_city_names[0] }}** (score: {{ top_city_market_score }}/100). The <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score combines population size, existing venue density, and data quality — a high score indicates a large addressable market with validated pricing data. However, the best city for *you* depends on land availability, local competition, and your target customer profile. Use the <a href="/{{ language }}/planner">financial planner</a> to model different locations side by side.
</details>
<details>
<summary>What are typical padel court prices in {{ country_name_en }}?</summary>
**What are typical padel court prices in {{ country_name_en }}?**
{% if median_peak_rate %}Based on live Playtomic data, median peak rates across {{ country_name_en }} cities are **{{ median_peak_rate | int }} {{ price_currency }}/hr** and off-peak rates are around **{% if median_offpeak_rate %}{{ median_offpeak_rate | int }}{% else %}—{% endif %} {{ price_currency }}/hr**. Individual cities vary — see each city's page for local benchmarks.{% else %}Pricing data is not yet available in aggregate for {{ country_name_en }}. Check individual city pages where Playtomic data is available.{% endif %}
</details>
<details>
<summary>How fast is padel growing in {{ country_name_en }}?</summary>
**How fast is padel growing in {{ country_name_en }}?**
Padel is one of the fastest-growing racquet sports in Europe. With {{ total_venues }} venues tracked across {{ city_count }} cities, {{ country_name_en }} shows {% if avg_market_score >= 65 %}a mature infrastructure — growth here comes mainly from increasing play frequency and premium offerings{% elif avg_market_score >= 40 %}clear growth momentum with rising demand and new venues opening{% else %}early-stage growth with significant potential for new entrants{% endif %}. The sport benefits from a low barrier to entry, high enjoyment factor, and strong word-of-mouth growth among players.
</details>
<details>
<summary>Which cities have the best pricing data?</summary>
**Which cities have the best pricing data?**
Cities with higher <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Scores (like {{ top_city_names[0] }}) typically have the most comprehensive pricing data, because more venues are listed on Playtomic. Browse our <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }} market overview</a> to see all cities ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score.
</details>
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →

View File

@@ -8,7 +8,7 @@
{% endif %}
<div class="flex items-center gap-2">
{% if article.country %}
<span class="badge">{{ article.country }}</span>
<span class="badge">{{ article.country | country_name(lang) }}</span>
{% endif %}
{% if article.region %}
<span class="text-xs text-slate">{{ article.region }}</span>

View File

@@ -4,13 +4,14 @@ Core infrastructure: database, config, email, and shared utilities.
import hashlib
import hmac
import logging
import os
import random
import re
import secrets
import unicodedata
from contextvars import ContextVar
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from functools import wraps
from pathlib import Path
@@ -88,6 +89,43 @@ class Config:
config = Config()
def setup_logging() -> None:
"""Configure root logger. Call once from each entry point (app, worker, scripts)."""
level_name = os.environ.get("LOG_LEVEL", "DEBUG" if config.DEBUG else "INFO")
level = getattr(logging, level_name.upper(), logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logging.getLogger("hypercorn").setLevel(logging.WARNING)
logging.getLogger("hypercorn.error").setLevel(logging.WARNING)
logging.getLogger("hypercorn.access").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# =============================================================================
# Datetime helpers
# =============================================================================
def utcnow() -> datetime:
"""Timezone-aware UTC now (replaces deprecated datetime.utcnow())."""
return datetime.now(UTC)
def utcnow_iso() -> str:
"""UTC now as naive ISO string for SQLite TEXT columns.
Produces YYYY-MM-DD HH:MM:SS (space separator, no +00:00 suffix) to match
SQLite's native datetime('now') format so lexicographic SQL comparisons work.
"""
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
# =============================================================================
# Database
# =============================================================================
@@ -364,7 +402,7 @@ async def send_email(
resend_id = None
if not config.RESEND_API_KEY:
print(f"[EMAIL] Would send to {to}: {subject}")
logger.info("Would send to %s: %s", to, subject)
resend_id = "dev"
else:
resend.api_key = config.RESEND_API_KEY
@@ -380,7 +418,7 @@ async def send_email(
)
resend_id = result.get("id") if isinstance(result, dict) else getattr(result, "id", None)
except Exception as e:
print(f"[EMAIL] Error sending to {to}: {e}")
logger.error("Error sending to %s: %s", to, e)
return None
# Log to email_log (best-effort, never fail the send)
@@ -391,7 +429,7 @@ async def send_email(
(resend_id, sender, to, subject, email_type),
)
except Exception as e:
print(f"[EMAIL] Failed to log email: {e}")
logger.error("Failed to log email: %s", e)
return resend_id
@@ -528,17 +566,18 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
"""
limit = limit or config.RATE_LIMIT_REQUESTS
window = window or config.RATE_LIMIT_WINDOW
now = datetime.utcnow()
now = utcnow()
window_start = now - timedelta(seconds=window)
# Clean old entries and count recent
await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat())
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
(key, window_start.strftime("%Y-%m-%d %H:%M:%S")),
)
result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
(key, window_start.isoformat()),
(key, window_start.strftime("%Y-%m-%d %H:%M:%S")),
)
count = result["count"] if result else 0
@@ -552,7 +591,10 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
return False, info
# Record this request
await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat()))
await execute(
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.strftime("%Y-%m-%d %H:%M:%S")),
)
return True, info
@@ -628,7 +670,7 @@ async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted."""
result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id),
(utcnow_iso(), id),
)
return result > 0
@@ -647,7 +689,7 @@ async def hard_delete(table: str, id: int) -> bool:
async def purge_deleted(table: str, days: int = 30) -> int:
"""Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
)

View File

@@ -5,9 +5,7 @@ All balance mutations go through this module to keep credit_ledger (source of tr
and suppliers.credit_balance (denormalized cache) in sync within a single transaction.
"""
from datetime import datetime
from .core import execute, fetch_all, fetch_one, transaction
from .core import execute, fetch_all, fetch_one, transaction, utcnow_iso
# Credit cost per heat tier
HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8}
@@ -44,7 +42,7 @@ async def add_credits(
note: str = None,
) -> int:
"""Add credits to a supplier. Returns new balance."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with transaction() as db:
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
@@ -73,7 +71,7 @@ async def spend_credits(
note: str = None,
) -> int:
"""Spend credits from a supplier. Returns new balance. Raises InsufficientCredits."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with transaction() as db:
row = await db.execute_fetchall(
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
@@ -116,7 +114,7 @@ async def unlock_lead(supplier_id: int, lead_id: int) -> dict:
raise ValueError("Lead not found")
cost = lead["credit_cost"] or compute_credit_cost(lead)
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with transaction() as db:
# Check balance
@@ -180,7 +178,7 @@ async def monthly_credit_refill(supplier_id: int) -> int:
if not row or not row["monthly_credits"]:
return 0
now = datetime.utcnow().isoformat()
now = utcnow_iso()
new_balance = await add_credits(
supplier_id,
row["monthly_credits"],
@@ -201,6 +199,6 @@ async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]:
FROM credit_ledger cl
LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock'
WHERE cl.supplier_id = ?
ORDER BY cl.created_at DESC LIMIT ?""",
ORDER BY cl.created_at DESC, cl.id DESC LIMIT ?""",
(supplier_id, limit),
)

View File

@@ -1,13 +1,12 @@
"""
Dashboard domain: user dashboard and settings.
"""
from datetime import datetime
from pathlib import Path
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
from ..auth.routes import login_required, update_user
from ..core import csrf_protect, fetch_one, soft_delete
from ..core import csrf_protect, fetch_one, soft_delete, utcnow_iso
from ..i18n import get_translations
bp = Blueprint(
@@ -57,7 +56,7 @@ async def settings():
await update_user(
g.user["id"],
name=form.get("name", "").strip() or None,
updated_at=datetime.utcnow().isoformat(),
updated_at=utcnow_iso(),
)
t = get_translations(g.get("lang") or "en")
await flash(t["dash_settings_saved"], "success")

View File

@@ -2,13 +2,12 @@
Supplier directory: public, searchable listing of padel court suppliers.
"""
from datetime import UTC, datetime
from pathlib import Path
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
from ..core import csrf_protect, execute, fetch_all, fetch_one
from ..i18n import get_translations
from ..core import csrf_protect, execute, fetch_all, fetch_one, utcnow_iso
from ..i18n import COUNTRY_LABELS, get_translations
bp = Blueprint(
"directory",
@@ -17,41 +16,6 @@ bp = Blueprint(
template_folder=str(Path(__file__).parent / "templates"),
)
COUNTRY_LABELS = {
"DE": "Germany",
"ES": "Spain",
"IT": "Italy",
"FR": "France",
"PT": "Portugal",
"GB": "United Kingdom",
"NL": "Netherlands",
"BE": "Belgium",
"SE": "Sweden",
"DK": "Denmark",
"FI": "Finland",
"NO": "Norway",
"AT": "Austria",
"SI": "Slovenia",
"IS": "Iceland",
"CH": "Switzerland",
"EE": "Estonia",
"US": "United States",
"CA": "Canada",
"MX": "Mexico",
"BR": "Brazil",
"AR": "Argentina",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
"CN": "China",
"IN": "India",
"SG": "Singapore",
"ID": "Indonesia",
"TH": "Thailand",
"AU": "Australia",
"ZA": "South Africa",
"EG": "Egypt",
}
CATEGORY_LABELS = {
"manufacturer": "Manufacturer",
@@ -89,7 +53,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
lang = g.get("lang", "en")
cat_labels, country_labels, region_labels = get_directory_labels(lang)
now = datetime.now(UTC).isoformat()
now = utcnow_iso()
params: list = []
wheres: list[str] = []

View File

@@ -13,6 +13,44 @@ from pathlib import Path
SUPPORTED_LANGS = {"en", "de"}
LANG_BLUEPRINTS = {"public", "planner", "directory", "content", "leads", "suppliers"}
# 2-letter ISO country code → English name.
# Used by the directory, article templates, and get_country_name().
COUNTRY_LABELS: dict[str, str] = {
"DE": "Germany",
"ES": "Spain",
"IT": "Italy",
"FR": "France",
"PT": "Portugal",
"GB": "United Kingdom",
"NL": "Netherlands",
"BE": "Belgium",
"SE": "Sweden",
"DK": "Denmark",
"FI": "Finland",
"NO": "Norway",
"AT": "Austria",
"SI": "Slovenia",
"IS": "Iceland",
"CH": "Switzerland",
"EE": "Estonia",
"US": "United States",
"CA": "Canada",
"MX": "Mexico",
"BR": "Brazil",
"AR": "Argentina",
"AE": "UAE",
"SA": "Saudi Arabia",
"TR": "Turkey",
"CN": "China",
"IN": "India",
"SG": "Singapore",
"ID": "Indonesia",
"TH": "Thailand",
"AU": "Australia",
"ZA": "South Africa",
"EG": "Egypt",
}
_LOCALES_DIR = Path(__file__).parent / "locales"
@@ -138,3 +176,30 @@ def get_calc_item_names(lang: str) -> dict[str, str]:
"""
assert lang in _CALC_ITEM_NAMES, f"Unknown lang: {lang!r}"
return _CALC_ITEM_NAMES[lang]
# Reverse map: English country name → 2-letter code (e.g. "Germany" → "DE").
# Built once at load time from COUNTRY_LABELS.
_COUNTRY_CODE_BY_EN_NAME: dict[str, str] = {v: k for k, v in COUNTRY_LABELS.items()}
def get_country_name(country_str: str, lang: str) -> str:
"""Return the localised name for a country stored as a 2-letter code or English name.
Handles both formats stored in the DB:
- 2-letter ISO code: "CH""Schweiz" (de) / "Switzerland" (en)
- English name: "Switzerland""Schweiz" (de)
Falls back to the original string if not found in translations.
Used as a Jinja filter: {{ article.country | country_name(lang) }}
"""
if not country_str:
return country_str
effective_lang = lang if lang in _TRANSLATIONS else "en"
# Accept both 2-letter code ("CH") and English name ("Switzerland")
upper = country_str.upper()
code = upper if upper in COUNTRY_LABELS else _COUNTRY_CODE_BY_EN_NAME.get(country_str, "")
if not code:
return country_str
key = f"dir_country_{code}"
return _TRANSLATIONS[effective_lang].get(key, country_str)

View File

@@ -4,7 +4,6 @@ Leads domain: capture interest in court suppliers and financing.
import json
import secrets
from datetime import datetime
from pathlib import Path
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
@@ -27,6 +26,7 @@ from ..core import (
is_disposable_email,
is_plausible_phone,
send_email,
utcnow_iso,
)
from ..i18n import get_translations
@@ -102,7 +102,7 @@ async def suppliers():
form.get("court_count", 0),
form.get("budget", 0),
form.get("message", ""),
datetime.utcnow().isoformat(),
utcnow_iso(),
),
)
# Notify admin
@@ -147,7 +147,7 @@ async def financing():
form.get("court_count", 0),
form.get("budget", 0),
form.get("message", ""),
datetime.utcnow().isoformat(),
utcnow_iso(),
),
)
await send_email(
@@ -375,7 +375,7 @@ async def quote_request():
status,
credit_cost,
secrets.token_urlsafe(16),
datetime.utcnow().isoformat(),
utcnow_iso(),
),
)
@@ -520,7 +520,7 @@ async def verify_quote():
from ..credits import compute_credit_cost
credit_cost = compute_credit_cost(dict(lead))
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?",
(now, credit_cost, lead["id"]),

View File

@@ -1,6 +1,6 @@
{
"nav_planner": "Finanzplaner",
"nav_quotes": "Angebot erhalten",
"nav_quotes": "Angebote anfragen",
"nav_directory": "Anbieterverzeichnis",
"nav_markets": "Märkte",
"nav_suppliers": "Für Anbieter",
@@ -14,7 +14,7 @@
"nav_section_plan": "Planen & Entdecken",
"nav_section_suppliers": "Anbieter",
"nav_section_account": "Konto",
"footer_tagline": "Plane, finanziere und baue dein Padel-Business.",
"footer_tagline": "Plane, finanziere und baue Dein Padel-Business.",
"footer_product": "Produkt",
"footer_legal": "Rechtliches",
"footer_company": "Unternehmen",
@@ -52,29 +52,29 @@
"auth_signup_have_account": "Bereits ein Konto?",
"auth_signup_signin_link": "Anmelden",
"auth_magic_title": "E-Mail prüfen",
"auth_magic_sent_to": "Wir haben dir einen Anmeldelink geschickt an:",
"auth_magic_instructions": "Klick auf den Link in der E-Mail, um dich anzumelden. Der Link läuft in {minutes} Minuten ab.",
"auth_magic_sent_to": "Wir haben Dir einen Anmeldelink geschickt an:",
"auth_magic_instructions": "Klick auf den Link in der E-Mail, um Dich anzumelden. Der Link läuft in {minutes} Minuten ab.",
"auth_magic_no_email": "Keine E-Mail erhalten?",
"auth_magic_check_spam": "Schau in deinen Spam-Ordner",
"auth_magic_correct_email": "Stelle sicher, dass die E-Mail-Adresse korrekt ist",
"auth_magic_wait": "Warte eine Minute und versuche es erneut",
"auth_magic_wait": "Warte einen Moment und versuche es erneut",
"auth_magic_resend_btn": "Link erneut senden",
"auth_waitlist_title": "Sei Erster beim Start deines Padel-Business",
"auth_waitlist_title": "Als Erster mit Deinem Padel-Business durchstarten",
"auth_waitlist_sub": "Wir bereiten die ultimative Planungsplattform für Padel-Unternehmer vor. Trag dich in die Warteliste ein für Frühzugang, exklusive Boni und priorisierten Support.",
"auth_waitlist_hint": "Du gehörst zu den Ersten, die Zugang erhalten, wenn wir launchen.",
"auth_waitlist_btn": "In Warteliste eintragen",
"auth_waitlist_confirmed_title": "Du stehst auf der Warteliste!",
"auth_waitlist_confirmed_sent_to": "Wir haben dir eine Bestätigung geschickt an:",
"auth_waitlist_confirmed_sub": "Du gehörst zu den Ersten, die es wissen, wenn wir launchen. Wir schicken dir Frühzugang, exklusive Launch-Boni und prioriertes Onboarding.",
"auth_waitlist_confirmed_sub": "Du gehörst zu den Ersten, die es erfahren, wenn wir launchen. Wir schicken Dir Frühzugang, exklusive Launch-Boni und bevorzugtes Onboarding.",
"auth_waitlist_confirmed_next": "Was passiert als Nächstes?",
"auth_waitlist_confirmed_step1": "Du erhältst in Kürze eine Bestätigungs-E-Mail",
"auth_waitlist_confirmed_step2": "Wir benachrichtigen dich, sobald wir launchen",
"auth_waitlist_confirmed_step2": "Wir benachrichtigen Dich, sobald wir launchen",
"auth_waitlist_confirmed_step3": "Du erhältst exklusiven Frühzugang vor dem öffentlichen Launch",
"auth_waitlist_confirmed_back": "Zurück zur Startseite",
"auth_flash_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
"auth_flash_disposable_email": "Bitte verwende eine dauerhafte E-Mail-Adresse.",
"auth_flash_login_sent": "Schau in deine E-Mails für den Anmeldelink!",
"auth_flash_account_exists": "Konto existiert bereits. Bitte melde dich an.",
"auth_flash_account_exists": "Konto bereits vorhanden. Bitte melde Dich an.",
"auth_flash_signup_sent": "Schau in deine E-Mails, um die Registrierung abzuschließen!",
"auth_flash_invalid_token": "Ungültiger oder abgelaufener Link.",
"auth_flash_invalid_token_detail": "Ungültiger oder abgelaufener Link. Bitte fordere einen neuen an.",
@@ -84,22 +84,22 @@
"flash_feedback_success": "Vielen Dank für dein Feedback!",
"flash_feedback_empty": "Bitte gib eine Nachricht ein.",
"flash_feedback_rate_limit": "Zu viele Anfragen. Bitte versuch es später erneut.",
"flash_suppliers_success": "Danke! Wir verbinden dich mit verifizierten Hoflieferanten.",
"flash_financing_success": "Danke! Wir verbinden dich mit Finanzierungspartnern.",
"flash_suppliers_success": "Danke! Wir vermitteln Dich an verifizierte Platz-Anbieter.",
"flash_financing_success": "Danke! Wir vermitteln Dich an Finanzierungspartner.",
"flash_verify_invalid": "Ungültiger Verifizierungslink.",
"flash_verify_expired": "Dieser Link ist abgelaufen oder wurde bereits verwendet. Bitte stelle eine neue Anfrage.",
"flash_verify_invalid_lead": "Dieses Angebot wurde bereits verifiziert oder existiert nicht.",
"landing_hero_badge": "Padel-Kostenrechner & Finanzplaner",
"landing_hero_badge": "Padel-Finanzrechner & Businessplan-Tool",
"landing_hero_h1_1": "Plan Dein Padel-",
"landing_hero_h1_2": "Business in Minuten,",
"landing_hero_h1_3": "nicht Monaten",
"landing_hero_btn_primary": "Jetzt planen →",
"landing_hero_btn_primary": "Jetzt Dein Padel-Business planen →",
"landing_hero_btn_secondary": "Anbieter durchsuchen",
"landing_hero_bullet_1": "Keine Registrierung erforderlich",
"landing_hero_bullet_2": "60+ Variablen",
"landing_hero_bullet_3": "Unbegrenzte Szenarien",
"landing_roi_title": "Schnelle Renditeschätzung",
"landing_roi_subtitle": "Schieberegler bewegen und Projektion sehen",
"landing_roi_subtitle": "Schieberegler bewegen und Projektion in Echtzeit sehen",
"landing_roi_courts": "Plätze",
"landing_roi_rate": "Durchschn. Stundensatz",
"landing_roi_util": "Ziel-Auslastung",
@@ -108,7 +108,7 @@
"landing_roi_payback": "Amortisationszeit",
"landing_roi_annual_roi": "Jährlicher ROI",
"landing_roi_note": "Annahmen: Indoorhalle Mietmodell, 8 €/m² Miete, Personalkosten, 5 % Zinsen, 10-jähriges Darlehen. Amortisation und ROI basieren auf der Gesamtinvestition.",
"landing_roi_cta": "Jetzt planen →",
"landing_roi_cta": "Jetzt Dein Padel-Business planen →",
"landing_journey_title": "Deine Reise",
"landing_journey_01": "Analysieren",
"landing_journey_01_badge": "Demnächst",
@@ -118,7 +118,7 @@
"landing_journey_04": "Bauen",
"landing_journey_05": "Wachsen",
"landing_journey_05_badge": "Demnächst",
"landing_features_title": "Für ernsthafte Padel-Unternehmer entwickelt",
"landing_features_title": "Für ernsthafte Padel-Unternehmer gebaut",
"landing_feature_1_h3": "60+ Variablen",
"landing_feature_2_h3": "6 Analyse-Tabs",
"landing_feature_3_h3": "Indoor & Outdoor",
@@ -137,9 +137,9 @@
"landing_faq_q4": "Ist das Anbieterverzeichnis kostenlos?",
"landing_faq_q5": "Wie genau sind die Finanzprojektionen?",
"landing_seo_title": "Padel-Platz-Investitionsplanung",
"landing_final_cta_h2": "Jetzt mit der Planung beginnen",
"landing_final_cta_btn": "Jetzt planen →",
"features_h1": "Alles, was du für dein Padel-Business brauchst",
"landing_final_cta_h2": "Jetzt mit der Planung loslegen",
"landing_final_cta_btn": "Jetzt Dein Padel-Business planen →",
"features_h1": "Alles, was Du für Dein Padel-Business brauchst",
"features_subtitle": "Professionelles Finanzmodell — vollständig kostenlos.",
"features_card_1_h2": "60+ Variablen",
"features_card_2_h2": "6 Analyse-Tabs",
@@ -154,19 +154,19 @@
"features_cta_open": "Planer öffnen",
"features_cta_signup": "Kostenloses Konto erstellen",
"about_why_h3": "Warum kostenlos?",
"about_next_h3": "Was kommt als nächstes",
"about_next_h3": "Was als Nächstes kommt",
"about_cta_open": "Planer öffnen",
"about_cta_signup": "Kostenloses Konto erstellen",
"suppliers_hero_cta": "Pläne & Preise ansehen",
"suppliers_stat_plans_label": "Erstellte Geschäftspläne",
"suppliers_stat_avg_value": "Durchschn. Projektwert",
"suppliers_stat_leads_label": "Leads diesen Monat",
"suppliers_problem_h2": "Das Problem bei der Kundengewinnung heute",
"suppliers_problem_sub": "Die meisten Kanäle verschwenden Zeit und Budget, bevor du mit einem echten Käufer sprichst.",
"suppliers_problem_h2": "Das Problem bei der Neukundengewinnung heute",
"suppliers_problem_sub": "Die meisten Kanäle verschwenden Zeit und Budget, bevor Du mit einem einzigen echten Käufer sprichst.",
"suppliers_problem_1_h3": "Messen",
"suppliers_problem_2_h3": "Google Ads",
"suppliers_problem_3_h3": "Kaltakquise",
"suppliers_transition": "Was wäre, wenn jeder Lead mit einem vollständigen Projektbrief und einem Finanzmodell käme?",
"suppliers_transition": "Was wäre, wenn jeder Lead mit einem vollständigen Projektbriefing und einem Finanzmodell käme?",
"suppliers_how_h2": "So funktioniert es",
"suppliers_how_sub": "Drei Schritte zu qualifizierten Leads.",
"suppliers_step_1_h3": "Eintrag beanspruchen",
@@ -205,8 +205,8 @@
"suppliers_boosts_sub": "Mit jedem bezahlten Plan verfügbar. Verwalte sie über Dein Dashboard.",
"suppliers_comparison_h2": "Der direkte Vergleich",
"suppliers_faq_h2": "FAQ für Anbieter",
"suppliers_final_cta_h2": "Dein nächster Kunde erstellt gerade einen Geschäftsplan",
"suppliers_final_cta_desc": "Er hat die Rentabilität berechnet. Er kennt sein Budget. Er sucht einen Anbieter wie dich.",
"suppliers_final_cta_h2": "Dein nächster Kunde erstellt gerade einen Businessplan",
"suppliers_final_cta_desc": "Er hat die Rentabilität berechnet. Er kennt sein Budget. Er sucht einen Anbieter wie Dich.",
"suppliers_final_cta_btn": "Pläne & Preise ansehen",
"planner_page_h2": "100 % kostenlos. Kein Haken.",
"planner_card_1_h3": "Finanzplaner",
@@ -220,7 +220,7 @@
"planner_card_2_signup_btn": "Registrieren und loslegen",
"planner_quote_cta_label": "Nächster Schritt",
"planner_quote_cta_title": "Angebote von verifizierten Anbietern einholen",
"planner_quote_cta_desc": "Teile Deine Projektspezifikationen und wir verbinden dich mit passenden Anbietern.",
"planner_quote_cta_desc": "Teile Deine Projektspezifikationen und wir vermitteln Dich an passende Anbieter.",
"planner_quote_cta_check_1": "Passende Anbieter",
"planner_quote_cta_check_2": "Direktkontakt, kein Vermittler",
"planner_quote_cta_check_3": "Keine Verpflichtung",
@@ -245,12 +245,12 @@
"export_back": "← Zurück zum Planer",
"export_success_title": "Zahlung eingegangen",
"export_success_subtitle": "Dein Geschäftsplan-PDF wird generiert. Dies dauert üblicherweise weniger als eine Minute.",
"export_success_status": "Dein PDF wird erstellt. Aktualisiere diese Seite gleich, oder überprüfe Deine E-Mail — wir senden Dir einen Download-Link, wenn es fertig ist.",
"export_success_status": "Dein PDF wird erstellt. Aktualisiere diese Seite gleich, oder schau in Deine E-Mails — wir senden Dir einen Download-Link, wenn es fertig ist.",
"export_success_refresh": "Status aktualisieren",
"export_success_all": "Alle Exporte anzeigen",
"export_success_planner": "Zurück zum Planer",
"export_gen_title": "Geschäftsplan wird generiert",
"export_gen_subtitle": "Dies dauert üblicherweise weniger als eine Minute. Diese Seite wird automatisch aktualisiert.",
"export_gen_subtitle": "Dies dauert üblicherweise weniger als eine Minute. Diese Seite aktualisiert sich automatisch.",
"export_gen_refresh": "Jetzt aktualisieren",
"export_gen_all": "Alle Exporte anzeigen",
"export_waitlist_title": "Geschäftsplan-PDF-Export demnächst verfügbar",
@@ -264,9 +264,9 @@
"scenario_created": "Erstellt",
"dir_heading": "Padelplatz-Hersteller, Platzbauer & Anbieter",
"dir_page_title": "Padel-Platz Anbieterverzeichnis",
"dir_page_meta_desc": "Über {count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für dein Projekt finden.",
"dir_page_og_desc": "Über {count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.",
"dir_subheading": "Über {n} Anbieter aus {c} Ländern. Hersteller, Platzbauer und schlüsselfertige Lösungen für Deinen Padelplatz.",
"dir_page_meta_desc": "{count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für Dein Projekt finden.",
"dir_page_og_desc": "{count}+ Anbieter aus {countries} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.",
"dir_subheading": "{n}+ Anbieter aus {c} Ländern. Hersteller, Platzbauer und schlüsselfertige Lösungen für Deine Padel-Anlage.",
"dir_stat_suppliers": "Anbieter",
"dir_stat_countries": "Länder",
"dir_stat_categories": "Kategorien",
@@ -276,7 +276,7 @@
"dir_search_btn": "Suchen",
"dir_filter_clear": "Alle löschen",
"dir_cta_heading": "Bist Du ein Padelplatz-Anbieter?",
"dir_cta_subheading": "Eintrag erstellen und Kontakt zu planenden Unternehmern aufnehmen.",
"dir_cta_subheading": "Eintrag erstellen und Kontakt zu planenden Unternehmern herstellen.",
"dir_cta_btn": "Eintrag erstellen",
"dir_card_verified": "Verifiziert",
"dir_card_featured": "Featured",
@@ -352,16 +352,16 @@
"sp_about": "Über uns",
"sp_services": "Angebotene Leistungen",
"sp_service_area": "Servicegebiet",
"sp_enquiry_heading": "Anfrage senden",
"sp_enquiry_heading": "Anfrage stellen",
"sp_enquiry_name": "Dein Name",
"sp_enquiry_email": "E-Mail",
"sp_enquiry_message": "Nachricht",
"sp_enquiry_submit": "Anfrage senden",
"sp_enquiry_submit": "Anfrage absenden",
"sp_contact": "Kontakt",
"sp_years": "Jahre aktiv",
"sp_projects": "Projekte",
"sp_trust": "Verifizierter Eintrag — Identität und Inhaberschaft bestätigt",
"sp_cta_basic_h3": "Auf der Suche nach direkter Angebotsabstimmung?",
"sp_cta_basic_h3": "Direkte Angebote und Lead-Zugang gesucht?",
"sp_cta_claim_h3": "Ist das Dein Unternehmen?",
"sp_cta_claim_btn": "Eintrag beanspruchen →",
"sp_locked_hint": "Eintrag noch nicht verifiziert",
@@ -369,18 +369,18 @@
"sp_locked_popover_link": "Angebotsassistent nutzen →",
"sp_locked_popover_dismiss": "Schließen",
"sp_enquiry_placeholder": "Erzähl {name} von Deinem Projekt…",
"sp_cta_basic_desc": "Upgrade auf Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.",
"sp_cta_basic_desc": "Wechsle zu Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.",
"sp_cta_basic_btn": "Auf Growth upgraden →",
"sp_locked_popover_desc": "Dieser Anbieter hat seinen Eintrag noch nicht verifiziert. Nutze unseren Angebotsassistenten und wir vermitteln dich mit verifizierten Anbietern in Deiner Region.",
"sp_locked_popover_desc": "Dieser Anbieter hat seinen Eintrag noch nicht verifiziert. Nutze unseren Angebotsassistenten wir vermitteln Dich mit verifizierten Anbietern in Deiner Region.",
"sp_cta_claim_desc": "Beanspruche und verifiziere diesen Eintrag, um Projektanfragen von Padel-Entwicklern zu erhalten.",
"enquiry_success_title": "Anfrage gesendet!",
"enquiry_error_title": "Bitte korrigiere Folgendes:",
"enquiry_forwarded_msg": "Deine Nachricht wurde an {name} weitergeleitet. Der Anbieter meldet sich direkt bei dir.",
"enquiry_received_msg": "Deine Nachricht wurde empfangen. Das Team meldet sich in Kürze bei dir.",
"enquiry_forwarded_msg": "Deine Nachricht wurde an {name} weitergeleitet. Der Anbieter meldet sich direkt bei Dir.",
"enquiry_received_msg": "Deine Nachricht wurde empfangen. Das Team meldet sich in Kürze bei Dir.",
"q_btn_next": "Weiter →",
"q_btn_back": "← Zurück",
"q_btn_submit": "Absenden & Angebote erhalten →",
"q_page_title": "Angebote von Bauunternehmen erhalten",
"q_page_title": "Angebote von Bauunternehmen einholen",
"q_step_counter": "Schritt {step} von {total}",
"q1_heading": "Dein Projekt",
"q1_subheading": "Welche Art von Padel-Anlage planst Du?",
@@ -450,7 +450,7 @@
"q6_decision_partners": "Mit Partnern",
"q6_decision_committee": "Ausschuss / Vorstand",
"q7_heading": "Über Dich",
"q7_subheading": "Das hilft uns, dich mit den richtigen Anbietern zusammenzubringen.",
"q7_subheading": "Das hilft uns, Dich mit den richtigen Anbietern zusammenzubringen.",
"q7_role_label": "Du bist…",
"q7_role_entrepreneur": "Unternehmer / Investor",
"q7_role_tennis": "Tennis- / Sportclub",
@@ -477,7 +477,7 @@
"q8_additional_label": "Noch etwas?",
"q8_additional_placeholder": "Besondere Anforderungen, Fragen oder Hintergrundinformationen…",
"q9_heading": "Kontaktdaten",
"q9_subheading": "Wie sollen passende Anbieter dich erreichen?",
"q9_subheading": "Wie sollen passende Anbieter Dich erreichen?",
"q9_privacy_msg": "Deine Kontaktdaten werden nur mit geprüften Anbietern geteilt, die zu Deinen Projektspezifikationen passen.",
"q9_name_label": "Vollständiger Name",
"q9_email_label": "E-Mail",
@@ -492,7 +492,7 @@
"q9_error_email": "E-Mail ist erforderlich",
"q9_error_phone": "Telefonnummer ist erforderlich",
"qs_title": "Erfolgreich vermittelt!",
"qs_next_h2": "Was als nächstes passiert",
"qs_next_h2": "Was als Nächstes passiert",
"qs_step_1": "Anbieter prüfen Deinen Projektbrief und bereiten Angebote vor",
"qs_step_1_time": "Jetzt",
"qs_step_2": "Passende Anbieter kontaktieren Dich mit maßgeschneiderten Angeboten",
@@ -509,7 +509,7 @@
"qs_matched_court_suffix": "-Platz-",
"qs_matched_facility_fmt": "{type}-",
"qs_matched_project": "Projekt",
"qs_matched_post": "mit verifizierten Anbietern abgestimmt, die sich mit maßgeschneiderten Angeboten bei Dir melden.",
"qs_matched_post": "mit verifizierten Anbietern abgeglichen, die sich mit maßgeschneiderten Angeboten bei Dir melden.",
"qv_heading": "E-Mail prüfen",
"qv_link_expiry": "Der Link läuft in 60 Minuten ab.",
"qv_spam": "Spam-Ordner überprüfen",
@@ -517,7 +517,7 @@
"qv_wrong_email": "Falsche E-Mail?",
"qv_wrong_email_link": "Neue Anfrage stellen",
"qv_sent_msg": "Wir haben einen Verifizierungslink an folgende Adresse gesendet:",
"qv_instructions": "Klick auf den Link in der E-Mail, um Deine Adresse zu bestätigen und Deine Angebotsanfrage zu aktivieren. Dadurch wird auch Dein {app_name}-Konto erstellt und du wirst automatisch angemeldet.",
"qv_instructions": "Klick auf den Link in der E-Mail, um Deine Adresse zu bestätigen und Deine Angebotsanfrage zu aktivieren. Dadurch wird auch Dein {app_name}-Konto erstellt und Du wirst automatisch angemeldet.",
"qv_no_email": "E-Mail nicht erhalten?",
"qv_check_email_pre": "Stell sicher, dass ",
"qv_check_email_post": " korrekt ist",
@@ -529,20 +529,20 @@
"sup_signup_of_steps": "von 4",
"sup_success_h2": "Alles bereit!",
"sup_success_text": "Dein Anbieter-Konto wird aktiviert. Du erhältst in Kürze qualifizierte Leads, die Deinen Leistungen entsprechen.",
"sup_success_next_h3": "Was als nächstes passiert:",
"sup_success_next_h3": "Was als Nächstes passiert:",
"sup_success_btn": "Zum Lead-Feed",
"sup_success_page_title": "Willkommen!",
"sup_success_li1": "Dein Eintrag wird in wenigen Minuten aktualisiert",
"sup_success_li2": "Lead-Credits wurden deinem Konto hinzugefügt",
"sup_success_li3": "Prüfe deine E-Mail auf einen Anmelde-Link",
"sup_success_li4": "Durchsuche und entsperre Leads in deinem Feed",
"sup_waitlist_h1": "Auf die Warteliste für die Anbieter-Plattform",
"sup_waitlist_h1": "Auf die Anbieter-Plattform-Warteliste eintragen",
"sup_waitlist_email_label": "E-Mail",
"sup_waitlist_submit": "Zur Warteliste",
"sup_waitlist_signin_text": "Bereits ein Konto?",
"sup_waitlist_signin_link": "Anmelden",
"sup_waitlist_page_title": "Anbieter-Warteliste",
"sup_waitlist_intro": "Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Sei Erster in der Schlange für den {plan_name}-Tier-Zugang.",
"sup_waitlist_intro": "Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Trag Dich als Erster für den {plan_name}-Tier-Zugang ein.",
"sup_waitlist_plan_h3": "{name} Plan-Highlights",
"sup_waitlist_hint": "Frühzeitiger Zugang, exklusiver Launch-Preis und bevorzugtes Onboarding.",
"sup_waitlist_conf_page_title": "Du stehst auf der Anbieter-Warteliste",
@@ -550,7 +550,7 @@
"sup_waitlist_conf_msg": "Wir haben eine Bestätigung gesendet an:",
"sup_waitlist_conf_first_pre": "Du gehörst zu den ersten Anbietern mit Zugang zum ",
"sup_waitlist_conf_first_post": "-Tier bei unserem Launch.",
"sup_waitlist_conf_early_h3": "Was du als Frühmitglied erhältst:",
"sup_waitlist_conf_early_h3": "Was Du als frühes Mitglied erhältst:",
"sup_waitlist_conf_li1": "Erster Zugang zu qualifizierten Leads von Padel-Unternehmern",
"sup_waitlist_conf_li2": "Exklusiver Launch-Preis (für 12 Monate festgeschrieben)",
"sup_waitlist_conf_li3": "Vorrangiges Onboarding und Support bei der Eintragsoptimierung",
@@ -577,7 +577,7 @@
"sup_step3_free_desc": "Nur Plan-Credits",
"sup_step3_next": "Weiter: Deine Daten",
"sup_step4_title": "Kontodaten",
"sup_step4_sub": "Erzähl uns von deinem Unternehmen und wie wir dich erreichen können.",
"sup_step4_sub": "Erzähl uns von Deinem Unternehmen und wie wir Dich erreichen können.",
"sup_step4_contact_name": "Ansprechpartner",
"sup_step4_email": "E-Mail",
"sup_step4_phone": "Telefon",
@@ -710,8 +710,8 @@
"sl_hold_years": "Haltedauer",
"sl_exit_multiple": "Exit-EBITDA-Multiplikator",
"sl_annual_rev_growth": "Jährliches Umsatzwachstum",
"wiz_summary_label": "Aktuelle Werte",
"tip_permits_compliance": "Baugenehmigungen, Lärmgutachten, Nutzungsänderungen, Brandschutz und behördliche Auflagen. Wird automatisch angepasst, wenn du ein Land wählst — kann manuell überschrieben werden.",
"wiz_summary_label": "Aktuelle Zusammenfassung",
"tip_permits_compliance": "Baugenehmigungen, Lärmgutachten, Nutzungsänderungen, Brandschutz und behördliche Auflagen. Wird automatisch angepasst, wenn Du ein Land wählst — kann manuell überschrieben werden.",
"tip_dbl_courts": "Standard-Padelplatz für 4 Spieler. Häufigstes Format mit der höchsten Freizeitnachfrage.",
"tip_sgl_courts": "Schmaler Platz für 2 Spieler. Beliebt für Coaching, Training und Wettkampf.",
"tip_sqm_dbl_hall": "Gesamte Hallenfläche pro Doppelplatz. Enthält Spielfeld (200m²), Sicherheitszonen, Laufwege und Mindestabstände. Standard: 300350m².",
@@ -723,7 +723,7 @@
"tip_rate_single": "Stundensatz für Einzelplätze. Meist niedriger als Doppelplätze, da sich weniger Spieler die Kosten teilen.",
"tip_peak_pct": "Anteil der gebuchten Stunden zum Spitzentarif. Höherer Wert bedeutet mehr Umsatz, aber schwieriger zu füllende Nebenstunden.",
"tip_booking_fee": "Provision von Buchungsplattformen wie Playtomic oder Matchi. Typisch: 515 % des Platzumsatzes.",
"tip_util_target": "Anteil der verfügbaren Platzstunden, der tatsächlich gebucht wird. 3545 % sind realistisch für neue Anlagen, 50 %+ ist stark.",
"tip_util_target": "Anteil der verfügbaren Platzstunden, der tatsächlich gebucht wird. 3545 % sind realistisch für neue Anlagen, ab 50 % ist stark.",
"tip_hours_per_day": "Gesamte Betriebsstunden pro Tag. Typische Padel-Anlagen öffnen 723Uhr (16h). Manche auch 624Uhr.",
"tip_days_indoor": "Durchschnittliche Betriebstage pro Monat für Indoor-Anlagen. ~29 berücksichtigt Feiertage und Wartungsschließungen.",
"tip_days_outdoor": "Durchschnittliche bespielbaren Tage pro Monat im Freien. Reduziert durch Regen, Extremhitze oder Kälte.",
@@ -750,7 +750,7 @@
"tip_outdoor_site_work": "Geländeausgleich, Entwässerungsinstallation, Versorgungsanschlüsse und Erschließung für Außenplätze.",
"tip_outdoor_lighting": "Flutlichtinstallation pro Platz. LED empfohlen für Energieeffizienz. Wettkampfnormen einhalten, falls relevant.",
"tip_outdoor_fencing": "Einzäunung der Außenplatzanlage. Enthält Windschutz, Sicherheitstore und Ballrückhaltevorrichtungen.",
"tip_working_capital": "Kassenreserve für Betriebsverluste in der Anlaufphase und bei saisonalen Schwankungen. Kritischer Puffer — zu geringes Betriebskapital ist ein häufiger Startup-Fehler.",
"tip_working_capital": "Kassenreserve für Betriebsverluste in der Anlaufphase und bei saisonalen Schwankungen. Kritischer Puffer — zu geringes Betriebskapital ist ein häufiger Fehler in der Startphase.",
"tip_contingency": "Prozentualer Puffer auf den Gesamt-CAPEX für unvorhergesehene Kosten. 1015 % sind beim Bau Standard, 1520 % bei komplexen Projekten.",
"tip_budget_target": "Gesamtbudget festlegen, um den geplanten CAPEX zu vergleichen. 0 lassen, um den Budgetindikator auszublenden.",
"tip_rent_sqm": "Monatliche Miete pro m² für Hallenfläche. Abhängig von Lage, Gebäudequalität und Mietkonditionen.",
@@ -764,11 +764,11 @@
"tip_cleaning": "Monatliche professionelle Reinigung von Plätzen, Umkleiden, Gemeinschaftsflächen und Empfang.",
"tip_marketing": "Monatliche Ausgaben für Marketing, Buchungsplattform-Abonnements, Website, Social Media und Kundengewinnung.",
"tip_staff": "Monatliche Personalkosten: Gehälter, Sozialabgaben und Leistungen. Viele Anlagen fahren schlank mit automatisierten Buchungs- und Zugangssystemen.",
"tip_loan_pct": "Anteil des Gesamt-CAPEX, der fremdfinanziert wird. Banken bieten typisch 7085 %. Höher mit Bürgüschaft oder Fördermitteln.",
"tip_loan_pct": "Anteil des Gesamt-CAPEX, der fremdfinanziert wird. Banken bieten typisch 7085 %. Höher mit Bürgschaft oder Fördermitteln.",
"tip_interest_rate": "Jährlicher Zinssatz des Darlehens. Abhängig von Bonität, Sicherheiten, Marktlage und Bankbeziehung.",
"tip_loan_term": "Kreditlaufzeit in Jahren. Längere Laufzeit bedeutet niedrigere Monatsraten, aber mehr Gesamtzinsen.",
"tip_construction_months": "Monate Bau/Einrichtung vor der Eröffnung. Kosten laufen bereits auf (Zinsen, Miete), aber noch kein Umsatz.",
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch für PE/Investoren: 57 Jahre. Betreiber-Eigentümer können unbegrenzt halten.",
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch für PE/Investoren: 57 Jahre. Eigentümer-Betreiber können unbegrenzt halten.",
"tip_exit_multiple": "EBITDA-Multiplikator zur Unternehmensbewertung beim Exit. Spiegelt Marktnachfrage, Markenstärke und Wachstumspotenzial wider. Kleines Business: 46×, starke Marke: 68×.",
"tip_annual_rev_growth": "Erwartetes jährliches Umsatzwachstum nach der ersten 12-monatigen Anlaufphase. Getrieben durch Preiserhöhungen und steigende Auslastung.",
"tip_result_irr": "Interner Zinsfuß über den Haltezeitraum — der annualisierte Diskontsatz, bei dem der Barwert aller Cashflows null ergibt. Ziel: über 20 %. N/A wenn Cashflows nie positiv werden.",
@@ -783,7 +783,7 @@
"tip_result_yield_on_cost": "Stabilisiertes EBITDA ÷ Gesamtinvestition (CAPEX). Ungehebelte Rendite — nützlich zum Vergleich mit anderen Anlageklassen oder Bauprojekten.",
"btn_save": "Speichern",
"btn_my_scenarios": "Meine Szenarien",
"btn_reset": "Zurücksetzen",
"btn_reset": "Auf Standardwerte zurücksetzen",
"btn_reset_confirm": "Sicher? Zurücksetzen",
"btn_back": "← Zurück",
"btn_next": "Weiter →",
@@ -911,12 +911,12 @@
"sup_prob_transition": "Was wäre, wenn jeder Lead mit einem vollständigen Projektbriefing und einem Finanzmodell käme?",
"sup_how_h2": "So funktioniert es",
"sup_how_sub": "Drei Schritte zu qualifizierten Leads.",
"sup_how_step1_h3": "Dein Inserat beanspruchen",
"sup_how_step1_p": "Dein Unternehmen ist bereits in unserem Verzeichnis. Wähle einen Plan, um dein Inserat aufzuwerten und Zugang zum Lead-Feed zu erhalten.",
"sup_how_step1_h3": "Deinen Eintrag beanspruchen",
"sup_how_step1_p": "Dein Unternehmen ist bereits in unserem Verzeichnis. Wähle einen Plan, um Deinen Eintrag aufzuwerten und Zugang zum Lead-Feed zu erhalten.",
"sup_how_step2_h3": "Vorqualifizierte Leads durchsuchen",
"sup_how_step2_p": "Jeder Lead enthält Projektspezifikationen, Budget, Zeitplan und ein selbst erstelltes Finanzmodell. Setze Credits nur für Leads ein, die zu deinen Leistungen passen.",
"sup_how_step2_p": "Jeder Lead enthält Projektspezifikationen, Budget, Zeitplan und ein selbst erstelltes Finanzmodell. Setze Credits nur für Leads ein, die zu Deinen Leistungen passen.",
"sup_how_step3_h3": "Projekte schneller gewinnen",
"sup_how_step3_p": "Kontaktiere den Unternehmer direkt. Du kennst bereits sein Budget, Zeitplan und Finanzierungsstatus kein Discovery-Call nötig.",
"sup_how_step3_p": "Kontaktiere den Unternehmer direkt. Du kennst bereits sein Budget, seinen Zeitplan und seinen Finanzierungsstatus kein Discovery-Call nötig.",
"sup_credits_h3": "Wie Credits funktionieren",
"sup_credits_sub": "Jeder Lead kostet Credits, je nachdem wie kaufbereit er ist. Growth-Pläne beinhalten 30 Credits/Monat, Pro 100.",
"sup_credits_hot": "Heißer Lead",
@@ -986,7 +986,7 @@
"sup_boost_sticky": "Sticky Top",
"sup_boost_color": "Eigene Kartenfarbe",
"sup_cmp_h2": "So schlagen wir den Vergleich",
"sup_cmp_sub": "Deine Interessenten wägen diese Alternativen bereits ab. Hier der ehrliche Vergleich.",
"sup_cmp_sub": "Deine Interessenten wägen diese Alternativen bereits ab — hier der ehrliche Vergleich.",
"sup_cmp_th_us": "Padelnomics Growth",
"sup_cmp_th_tradeshow": "Messepräsenz",
"sup_cmp_th_ads": "Google Ads",
@@ -1012,7 +1012,7 @@
"sup_cmp_t4": "Nie",
"sup_cmp_m1": "Nach Kategorie gefiltert",
"sup_cmp_footnote": "*Google-Ads-Schätzung basierend auf €2080 CPC für Padel-Baukeywords bei 510 Klicks/Tag.",
"sup_proof_h2": "Vertrauen von Führungsunternehmen der Padel-Branche",
"sup_proof_h2": "Vertrauen von führenden Unternehmen der Padel-Branche",
"sup_proof_stat1": "erstellte Businesspläne",
"sup_proof_stat2": "Anbieter",
"sup_proof_stat3": "Länder",
@@ -1023,7 +1023,7 @@
"sup_faq_h2": "Anbieter-FAQ",
"sup_faq_q1": "Wie werde ich gelistet?",
"sup_faq_a1_pre": "Finde dein Unternehmen in unserem",
"sup_faq_a1_post": "und klicke auf „Ist das dein Unternehmen?“ Wir überprüfen deine Identität und geben dir Zugang, um einen Plan auszuwählen und dein Profil zu aktualisieren.",
"sup_faq_a1_post": "und klicke auf „Ist das Dein Unternehmen?“ Wir prüfen Deine Identität und geben Dir Zugang, um einen Plan auszuwählen und Dein Profil zu aktualisieren.",
"sup_faq_dir_link": "Verzeichnis",
"sup_faq_q2": "Wie viel kostet es?",
"sup_faq_a2": "Wir bieten drei Pläne an: Basic (€39/Monat) für einen verifizierten Verzeichniseintrag mit Kontaktformular; Growth (€199/Monat, 30 Credits) mit vollem Lead-Zugang und Prioritätsplatzierung; und Pro (€499/Monat, 100 Credits) für maximale Sichtbarkeit und Lead-Volumen. Jährliche Abrechnung spart bis zu 26 % Basic bei €349/Jahr, Growth bei €1.799/Jahr, Pro bei €4.499/Jahr. Optionale Boost-Add-Ons sind zusätzlich erhältlich.",
@@ -1046,7 +1046,7 @@
"sup_faq_a10_pre": "Schreib uns eine E-Mail an",
"sup_faq_a10_post": "mit deinen Unternehmensdetails und wir fügen dich innerhalb von 48 Stunden dem Verzeichnis hinzu.",
"sup_cta_h2": "Dein nächster Kunde erstellt gerade einen Businessplan",
"sup_cta_p": "Er hat den ROI modelliert. Er kennt sein Budget. Er sucht einen Anbieter wie dich.",
"sup_cta_p": "Er hat den ROI berechnet. Er kennt sein Budget. Er sucht einen Anbieter wie Dich.",
"scenario_cta_try_numbers": "Mit eigenen Zahlen testen →",
"scenario_payback_label": "Amortisation",
"scenario_months_unit": "Monate",
@@ -1155,10 +1155,10 @@
"about_meta_desc": "Padelnomics ist eine kostenlose Finanzplanungsplattform für Padel-Unternehmer. Modelliere deine Investition, finde Anbieter und plane dein Padel-Business mit professionellen Tools.",
"about_og_desc": "Entwickelt für Padel-Unternehmer, die professionelle Finanztools ohne Beratungskosten benötigen. Kostenloser Planer, 60+ Variablen, Anbieterverzeichnis und mehr.",
"about_h1": "Über Padelnomics",
"about_body_p1": "Padel ist eine der am schnellsten wachsenden Sportarten weltweit, doch für die meisten Unternehmer ist die Eröffnung einer Paddelhalle immer noch ein Sprung ins Ungewisse. Die Finanzen sind komplex: Der CAPEX variiert stark je nach Anlagentyp, der Standort bestimmt die Auslastung, und der Unterschied zwischen 60 % und 75 % Belegung kann über Erfolg oder Misserfolg einer Investition entscheiden.",
"about_body_p2": "Wir haben Padelnomics gebaut, weil wir kein ausreichend gutes Finanzplanungstool gefunden haben. Vorhandene Rechner sind entweder zu simpel (5 Eingaben, ein Ergebnis) oder hinter teuren Beratungsmandaten verborgen. Wir wollten etwas mit der Tiefe eines professionellen Finanzmodells, aber der Zugänglichkeit einer Web-App.",
"about_body_p1": "Padel ist eine der am schnellsten wachsenden Sportarten weltweit, doch für die meisten Unternehmer ist die Eröffnung einer Padel-Anlage noch immer ein Sprung ins Ungewisse. Die Finanzen sind komplex: Der CAPEX variiert stark je nach Anlagentyp, der Standort bestimmt die Auslastung, und der Unterschied zwischen 60 % und 75 % Belegung kann über Erfolg oder Misserfolg einer Investition entscheiden.",
"about_body_p2": "Wir haben Padelnomics gebaut, weil wir kein ausreichend gutes Finanzplanungstool gefunden haben. Vorhandene Rechner sind entweder zu simpel (5 Eingaben, ein Ergebnis) oder hinter teuren Beratungsmandaten versteckt. Wir wollten etwas mit der Tiefe eines professionellen Finanzmodells, aber der Zugänglichkeit einer Web-App.",
"about_body_p3": "Das Ergebnis ist ein kostenloser Finanzplaner mit 60+ anpassbaren Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und den professionellen Kennzahlen, die Banken und Investoren sehen müssen. Jede Annahme ist transparent und anpassbar. Keine Blackboxen.",
"about_why_p": "Der Planer ist kostenlos, weil wir glauben, dass bessere Planung zu besseren Padelanlagen führt — und das ist gut für die gesamte Branche. Wir verdienen Geld, indem wir Unternehmer mit Platz-Anbietern und Finanzierungspartnern verbinden, wenn sie bereit sind, von der Planung zum Bau überzugehen.",
"about_why_p": "Der Planer ist kostenlos, weil wir glauben, dass bessere Planung zu besseren Padelanlagen führt — und das ist gut für die gesamte Branche. Wir verdienen Geld, indem wir Unternehmer mit Platz-Anbietern und Finanzierungspartnern verbinden, wenn sie bereit sind, von der Planung in den Bau zu wechseln.",
"about_next_p": "Padelnomics baut die Infrastruktur für Padel-Unternehmertum auf. Nach der Planung kommen Finanzierung, Bau und Betrieb. Wir arbeiten an Marktintelligenz auf Basis realer Buchungsdaten, einem Anbietermarktplatz für Platzausstattung und Analyse-Tools für Betreiber.",
"features_title_prefix": "Funktionen - Padel-Kostenrechner & Finanzplaner",
"features_meta_desc": "60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für deine Padelplatz-Investition.",
@@ -1175,11 +1175,11 @@
"landing_page_title": "Padelnomics - Padel-Kostenrechner & Finanzplaner",
"landing_meta_desc": "Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.",
"landing_og_desc": "Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.",
"landing_hero_desc": "Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Dann wirst du mit verifizierten Anbietern zusammengebracht.",
"landing_hero_desc": "Modelliere Deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Danach wirst Du mit verifizierten Anbietern zusammengebracht.",
"landing_journey_01_desc": "Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.",
"landing_journey_02_desc": "Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.",
"landing_journey_03_desc": "Kontakte zu Banken und Investoren herstellen. Dein Finanzplan wird zum Businesscase.",
"landing_journey_04_desc": "Über {total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu deinen Anforderungen vermittelt.",
"landing_journey_04_desc": "{total_suppliers}+ Platz-Anbieter aus {total_countries} Ländern durchsuchen. Passend zu Deinen Anforderungen vermittelt.",
"landing_journey_05_desc": "Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für deinen Betrieb.",
"landing_feature_1_body": "Jede Annahme ist anpassbar: Platzbaukosten, Miete, Preisgestaltung, Auslastung, Finanzierungskonditionen, Exit-Szenarien. Nichts ist fest vorgegeben.",
"landing_feature_2_body": "Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen — jeder Tab mit interaktiven Diagrammen.",
@@ -1196,9 +1196,9 @@
"landing_faq_a3": "Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten.",
"landing_faq_a4": "Das Durchsuchen des Verzeichnisses ist für alle kostenlos. Anbieter erhalten standardmäßig einen Basiseintrag. Kostenpflichtige Pläne (Basic ab 39 €/Monat, Growth ab 199 €/Monat, Pro ab 499 €/Monat) schalten Anfrageformulare, vollständige Beschreibungen, Logos, verifizierte Badges und Prioritätsplatzierung frei.",
"landing_faq_a5": "Das Modell verwendet reale Standardwerte auf Basis globaler Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.",
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Paddelhalle zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.",
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob du als Unternehmer deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Paddelhalle bewertest — Padelnomics gibt dir die finanzielle Klarheit für fundierte Entscheidungen.",
"landing_final_cta_sub": "Modelliere deine Investition und lass dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.",
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot in Märkten von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Padel-Anlage zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle mit 68 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 23 Mio. € (Neubau), mit Amortisationszeiten von 35 Jahren für gut gelegene Anlagen.",
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Du als Unternehmer Deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Padel-Anlage bewertest — Padelnomics gibt Dir die finanzielle Klarheit für fundierte Entscheidungen.",
"landing_final_cta_sub": "Modelliere Deine Investition und lass Dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.",
"landing_jsonld_org_desc": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer.",
"plan_basic_f1": "Verifiziert-Badge",
"plan_basic_f2": "Firmenlogo",
@@ -1259,7 +1259,7 @@
"billing_pricing_og_title": "Kostenloser Padel-Finanzplaner",
"billing_pricing_og_desc": "Plane deine Padel-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Keine Anmeldung erforderlich. Vollständig kostenlos.",
"billing_pricing_h1": "100% kostenlos. Kein Haken.",
"billing_pricing_subtitle": "Der ausgefeilteste Padel-Finanzplaner auf dem Markt — vollständig kostenlos. Plane deine Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen.",
"billing_pricing_subtitle": "Der ausgefeilteste Padel-Finanzplaner am Markt — vollständig kostenlos. Plane Deine Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen.",
"billing_planner_card": "Finanzplaner",
"billing_planner_free": "Kostenlos",
"billing_planner_forever": "— für immer",
@@ -1281,7 +1281,7 @@
"billing_signup": "Jetzt registrieren",
"billing_success_title": "Willkommen",
"billing_success_h1": "Willkommen bei Padelnomics!",
"billing_success_body": "Dein Konto ist bereit. Starte jetzt mit dem Planen deiner Padel-Investition.",
"billing_success_body": "Dein Konto ist bereit. Fang jetzt an, Deine Padel-Investition mit unserem Finanzplaner zu planen.",
"billing_success_btn": "Planer öffnen",
"billing_no_subscription": "Kein aktives Abonnement gefunden.",
"sd_page_title": "Anbieter-Dashboard",
@@ -1303,11 +1303,11 @@
"sd_ov_credits_balance": "Credits-Guthaben",
"sd_ov_directory_rank": "Verzeichnis-Rang",
"sd_ov_basic_plan_label": "Basic-Tarif",
"sd_ov_basic_plan_desc": "Du hast einen verifizierten Eintrag mit Kontaktformular. Wechsle zu Growth, um auf qualifizierte Projekt-Leads zuzugreifen.",
"sd_ov_basic_plan_desc": "Du hast einen verifizierten Eintrag mit Kontaktformular. Wechsle zu Growth, um Zugang zu qualifizierten Projekt-Leads zu erhalten.",
"sd_ov_upgrade_growth": "Auf Growth upgraden",
"sd_ov_recent_activity": "Letzte Aktivitäten",
"sd_ov_credits": "Credits",
"sd_ov_no_activity": "Noch keine Aktivitäten. Schalte deinen ersten Lead frei.",
"sd_ov_no_activity": "Noch keine Aktivitäten. Schalte Deinen ersten Lead frei.",
"sd_bst_current_plan": "Aktueller Tarif",
"sd_bst_credits_month": "Credits/Monat",
"sd_bst_per_mo": "/Monat",
@@ -1462,16 +1462,15 @@
"sd_boost_verified_name": "Verifiziert-Badge",
"sd_boost_verified_desc": "Verifiziertes Häkchen-Badge",
"sd_boost_card_color_name": "Individuelle Kartenfarbe",
"sd_boost_card_color_desc": "Hebe dich mit einer individuellen Randfarbe in deinem Verzeichniseintrag ab",
"sd_boost_card_color_desc": "Heb Dich mit einer individuellen Rahmenfarbe in Deinem Verzeichniseintrag ab",
"sd_billing_yearly": "jährlich abgerechnet zu €{price}/Jahr",
"sd_billing_monthly": "monatlich abgerechnet",
"sd_flash_signin": "Bitte melde dich an, um fortzufahren.",
"sd_flash_signin": "Bitte melde Dich an, um fortzufahren.",
"sd_flash_active_plan": "Du benötigst einen aktiven Anbieter-Tarif, um auf diese Seite zuzugreifen.",
"sd_flash_lead_access": "Lead-Zugang erfordert einen Growth- oder Pro-Tarif.",
"sd_flash_valid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
"sd_flash_claim_error": "Dieser Eintrag wurde bereits beansprucht oder existiert nicht.",
"sd_flash_listing_saved": "Eintrag erfolgreich gespeichert.",
"bp_indoor": "Indoor",
"bp_outdoor": "Outdoor",
"bp_own": "Kauf",
@@ -1480,49 +1479,41 @@
"bp_payback_not_reached": "Nicht in 60 Monaten erreicht",
"bp_months": "{n} Monate",
"bp_years": "{n} Jahre",
"bp_exec_paragraph": "Dieser Businessplan modelliert eine <strong>{facility_type}</strong>-Padel-Anlage mit <strong>{courts} Pl\u00e4tzen</strong> ({sqm} m\u00b2). Die Gesamtinvestition betr\u00e4gt {total_capex}, finanziert mit {equity} Eigenkapital und {loan} Fremdkapital. Die prognostizierte IRR betr\u00e4gt {irr} bei einer Amortisationszeit von {payback}.",
"bp_exec_paragraph": "Dieser Businessplan modelliert eine <strong>{facility_type}</strong>-Padel-Anlage mit <strong>{courts} Plätzen</strong> ({sqm} m²). Die Gesamtinvestition beträgt {total_capex}, finanziert mit {equity} Eigenkapital und {loan} Fremdkapital. Die prognostizierte IRR beträgt {irr} bei einer Amortisationszeit von {payback}.",
"bp_lbl_scenario": "Szenario",
"bp_lbl_generated_by": "Erstellt von Padelnomics \u2014 padelnomics.io",
"bp_lbl_generated_by": "Erstellt von Padelnomics padelnomics.io",
"bp_lbl_total_investment": "Gesamtinvestition",
"bp_lbl_equity_required": "Eigenkapitalbedarf",
"bp_lbl_year3_ebitda": "EBITDA Jahr 3",
"bp_lbl_irr": "IRR",
"bp_lbl_payback_period": "Amortisationszeit",
"bp_lbl_year1_revenue": "Umsatz Jahr 1",
"bp_lbl_item": "Position",
"bp_lbl_amount": "Betrag",
"bp_lbl_notes": "Hinweise",
"bp_lbl_total_capex": "Gesamt-CAPEX",
"bp_lbl_capex_stats": "CAPEX je Platz: {per_court} \u2022 CAPEX je m\u00b2: {per_sqm}",
"bp_lbl_capex_stats": "CAPEX je Platz: {per_court} CAPEX je m²: {per_sqm}",
"bp_lbl_equity": "Eigenkapital",
"bp_lbl_loan": "Darlehen",
"bp_lbl_interest_rate": "Zinssatz",
"bp_lbl_loan_term": "Darlehenslaufzeit",
"bp_lbl_monthly_payment": "Monatliche Rate",
"bp_lbl_annual_debt_service": "J\u00e4hrlicher Schuldendienst",
"bp_lbl_annual_debt_service": "Jährlicher Schuldendienst",
"bp_lbl_ltv": "Beleihungsauslauf",
"bp_lbl_monthly": "Monatlich",
"bp_lbl_total_monthly_opex": "Monatlicher OPEX gesamt",
"bp_lbl_annual_opex": "Jahres-OPEX",
"bp_lbl_weighted_hourly_rate": "Gewichteter Stundensatz",
"bp_lbl_target_utilization": "Zielauslastung",
"bp_lbl_gross_monthly_revenue": "Monatlicher Bruttoumsatz",
"bp_lbl_net_monthly_revenue": "Monatlicher Nettoumsatz",
"bp_lbl_monthly_ebitda": "Monatliches EBITDA",
"bp_lbl_monthly_net_cf": "Monatlicher Netto-Cashflow",
"bp_lbl_year": "Jahr",
"bp_lbl_revenue": "Umsatz",
"bp_lbl_ebitda": "EBITDA",
"bp_lbl_debt_service": "Schuldendienst",
"bp_lbl_net_cf": "Netto-CF",
"bp_lbl_moic": "MOIC",
"bp_lbl_cash_on_cash": "Cash-on-Cash (J3)",
"bp_lbl_payback": "Amortisation",
@@ -1530,116 +1521,148 @@
"bp_lbl_ebitda_margin": "EBITDA-Marge",
"bp_lbl_dscr_y3": "DSCR (J3)",
"bp_lbl_yield_on_cost": "Rendite auf Kosten",
"bp_lbl_month": "Monat",
"bp_lbl_opex": "OPEX",
"bp_lbl_debt": "Schulden",
"bp_lbl_cumulative": "Kumulativ",
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Sch\u00e4tzungen und stellen keine Finanzberatung dar. Die tats\u00e4chlichen Ergebnisse k\u00f6nnen je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. \u00a9 Padelnomics \u2014 padelnomics.io",
"bp_lbl_disclaimer": "<strong>Haftungsausschluss:</strong> Dieser Businessplan wurde auf Basis benutzerdefinierter Annahmen mit dem Padelnomics-Finanzmodell erstellt. Alle Prognosen sind Schätzungen und stellen keine Finanzberatung dar. Die tatsächlichen Ergebnisse können je nach Marktbedingungen, Umsetzung und anderen Faktoren erheblich abweichen. Konsultiere Finanzberater, bevor du Investitionsentscheidungen triffst. © Padelnomics — padelnomics.io",
"email_magic_link_heading": "Bei {app_name} anmelden",
"email_magic_link_body": "Hier ist dein Anmeldelink. Er l\u00e4uft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden \u2192",
"email_magic_link_body": "Hier ist dein Anmeldelink. Er läuft in {expiry_minutes} Minuten ab.",
"email_magic_link_btn": "Anmelden ",
"email_magic_link_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_magic_link_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_magic_link_subject": "Dein Anmeldelink f\u00fcr {app_name}",
"email_magic_link_preheader": "Dieser Link l\u00e4uft in {expiry_minutes} Minuten ab",
"email_quote_verify_heading": "Best\u00e4tige deine E-Mail f\u00fcr Angebote",
"email_magic_link_ignore": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail ignorieren.",
"email_magic_link_subject": "Dein Anmeldelink für {app_name}",
"email_magic_link_preheader": "Dieser Link läuft in {expiry_minutes} Minuten ab",
"email_quote_verify_heading": "Bestätige deine E-Mail für Angebote",
"email_quote_verify_greeting": "Hallo {first_name},",
"email_quote_verify_body": "Danke f\u00fcr deine Angebotsanfrage. Best\u00e4tige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
"email_quote_verify_body": "Danke für deine Angebotsanfrage. Bestätige deine E-Mail, um deine Anfrage zu aktivieren und dein {app_name}-Konto zu erstellen.",
"email_quote_verify_project_label": "Dein Projekt:",
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt behandelt.",
"email_quote_verify_btn": "Best\u00e4tigen & Aktivieren \u2192",
"email_quote_verify_expires": "Dieser Link l\u00e4uft in 60 Minuten ab.",
"email_quote_verify_urgency": "Verifizierte Anfragen werden von unserem Anbieternetzwerk bevorzugt bearbeitet.",
"email_quote_verify_btn": "Bestätigen & Aktivieren ",
"email_quote_verify_expires": "Dieser Link läuft in 60 Minuten ab.",
"email_quote_verify_fallback": "Wenn der Button nicht funktioniert, kopiere diese URL in deinen Browser:",
"email_quote_verify_ignore": "Wenn du das nicht angefordert hast, kannst du diese E-Mail ignorieren.",
"email_quote_verify_subject": "Best\u00e4tige deine E-Mail \u2014 Anbieter sind bereit f\u00fcr Angebote",
"email_quote_verify_ignore": "Falls Du das nicht angefordert hast, kannst Du diese E-Mail ignorieren.",
"email_quote_verify_subject": "Bestätige deine E-Mail Anbieter sind bereit für Angebote",
"email_quote_verify_preheader": "Ein Klick, um deine Angebotsanfrage zu aktivieren",
"email_quote_verify_preheader_courts": "Ein Klick, um dein {court_count}-Court-Projekt zu aktivieren",
"email_welcome_heading": "Willkommen bei {app_name}",
"email_welcome_greeting": "Hallo {first_name},",
"email_welcome_body": "Du hast jetzt Zugang zum Finanzplaner, Marktdaten und dem Anbieterverzeichnis \u2014 alles, was du f\u00fcr die Planung deines Padel-Gesch\u00e4fts brauchst.",
"email_welcome_body": "Du hast jetzt Zugang zum Finanzplaner, Marktdaten und dem Anbieterverzeichnis alles, was du für die Planung deines Padel-Geschäfts brauchst.",
"email_welcome_quickstart_heading": "Schnellstart:",
"email_welcome_link_planner": "Finanzplaner \u2014 modelliere deine Investition",
"email_welcome_link_markets": "Marktdaten \u2014 erkunde die Padel-Nachfrage nach Stadt",
"email_welcome_link_quotes": "Angebote einholen \u2014 verbinde dich mit verifizierten Anbietern",
"email_welcome_btn": "Jetzt planen \u2192",
"email_welcome_subject": "Du bist dabei \u2014 so f\u00e4ngst du an",
"email_welcome_link_planner": "Finanzplaner modelliere deine Investition",
"email_welcome_link_markets": "Marktdaten erkunde die Padel-Nachfrage nach Stadt",
"email_welcome_link_quotes": "Angebote einholen verbinde dich mit verifizierten Anbietern",
"email_welcome_btn": "Jetzt planen ",
"email_welcome_subject": "Du bist dabei — so fängst Du an",
"email_welcome_preheader": "Dein Padel-Planungstoolkit ist bereit",
"email_waitlist_supplier_heading": "Du stehst auf der Anbieter-Warteliste",
"email_waitlist_supplier_body": "Danke f\u00fcr dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen eine Plattform, die dich mit qualifizierten Leads von Padel-Unternehmern verbindet, die aktiv Projekte planen.",
"email_waitlist_supplier_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
"email_waitlist_supplier_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
"email_waitlist_supplier_body": "Danke für dein Interesse am <strong>{plan_name}</strong>-Plan. Wir bauen eine Plattform, die dich mit qualifizierten Leads von Padel-Unternehmern verbindet, die aktiv Projekte planen.",
"email_waitlist_supplier_perks_intro": "Als frühes Wartelisten-Mitglied erhältst du:",
"email_waitlist_supplier_perk_1": "Frühen Zugang vor dem öffentlichen Launch",
"email_waitlist_supplier_perk_2": "Exklusive Launch-Preise (gesichert)",
"email_waitlist_supplier_perk_3": "Pers\u00f6nliches Onboarding-Gespr\u00e4ch",
"email_waitlist_supplier_perk_3": "Persönliches Onboarding-Gespräch",
"email_waitlist_supplier_meanwhile": "In der Zwischenzeit erkunde unsere kostenlosen Ressourcen:",
"email_waitlist_supplier_link_planner": "Finanzplanungstool \u2014 plane deine Padel-Anlage",
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis \u2014 verifizierte Anbieter durchsuchen",
"email_waitlist_supplier_subject": "Du bist dabei \u2014 {plan_name} fr\u00fcher Zugang kommt",
"email_waitlist_supplier_link_planner": "Finanzplanungstool plane deine Padel-Anlage",
"email_waitlist_supplier_link_directory": "Anbieterverzeichnis verifizierte Anbieter durchsuchen",
"email_waitlist_supplier_subject": "Du bist dabei {plan_name} früher Zugang kommt",
"email_waitlist_supplier_preheader": "Exklusive Launch-Preise + bevorzugtes Onboarding",
"email_waitlist_general_heading": "Du stehst auf der Warteliste",
"email_waitlist_general_body": "Danke f\u00fcr deine Anmeldung. Wir bauen die Planungsplattform f\u00fcr Padel-Unternehmer \u2014 Finanzmodellierung, Marktdaten und Anbietervernetzung an einem Ort.",
"email_waitlist_general_perks_intro": "Als fr\u00fches Wartelisten-Mitglied erh\u00e4ltst du:",
"email_waitlist_general_perk_1": "Fr\u00fchen Zugang vor dem \u00f6ffentlichen Launch",
"email_waitlist_general_body": "Danke für deine Anmeldung. Wir bauen die Planungsplattform für Padel-Unternehmer Finanzmodellierung, Marktdaten und Anbietervernetzung an einem Ort.",
"email_waitlist_general_perks_intro": "Als frühes Wartelisten-Mitglied erhältst du:",
"email_waitlist_general_perk_1": "Frühen Zugang vor dem öffentlichen Launch",
"email_waitlist_general_perk_2": "Exklusive Launch-Preise",
"email_waitlist_general_perk_3": "Priorit\u00e4ts-Onboarding und Support",
"email_waitlist_general_outro": "Wir melden uns bald.",
"email_waitlist_general_subject": "Du stehst auf der Liste \u2014 wir benachrichtigen dich zum Launch",
"email_waitlist_general_preheader": "Fr\u00fcher Zugang + exklusive Launch-Preise",
"email_waitlist_general_perk_3": "Prioritäts-Onboarding und Support",
"email_waitlist_general_outro": "Wir melden uns in Kürze.",
"email_waitlist_general_subject": "Du stehst auf der Liste wir benachrichtigen dich zum Launch",
"email_waitlist_general_preheader": "Früher Zugang + exklusive Launch-Preise",
"email_lead_forward_heading": "Neues Projekt-Lead",
"email_lead_forward_urgency": "Dieses Lead wurde gerade freigeschaltet. Anbieter, die innerhalb von 24 Stunden antworten, gewinnen 3x h\u00e4ufiger das Projekt.",
"email_lead_forward_urgency": "Dieses Lead wurde gerade freigeschaltet. Anbieter, die innerhalb von 24 Stunden antworten, gewinnen das Projekt 3× häufiger.",
"email_lead_forward_section_brief": "Projektbeschreibung",
"email_lead_forward_section_contact": "Kontakt",
"email_lead_forward_lbl_facility": "Anlage",
"email_lead_forward_lbl_courts": "Pl\u00e4tze",
"email_lead_forward_lbl_courts": "Plätze",
"email_lead_forward_lbl_location": "Standort",
"email_lead_forward_lbl_timeline": "Zeitplan",
"email_lead_forward_lbl_phase": "Phase",
"email_lead_forward_lbl_services": "Leistungen",
"email_lead_forward_lbl_additional": "Zus\u00e4tzlich",
"email_lead_forward_lbl_additional": "Zusätzlich",
"email_lead_forward_lbl_name": "Name",
"email_lead_forward_lbl_email": "E-Mail",
"email_lead_forward_lbl_phone": "Telefon",
"email_lead_forward_lbl_company": "Unternehmen",
"email_lead_forward_lbl_role": "Rolle",
"email_lead_forward_btn": "Im Lead-Feed ansehen \u2192",
"email_lead_forward_btn": "Im Lead-Feed ansehen ",
"email_lead_forward_reply_direct": "oder <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;font-weight:500;\">direkt an {contact_email} antworten</a>",
"email_lead_forward_preheader_suffix": "Kontaktdaten enthalten",
"email_lead_matched_heading": "Ein Anbieter m\u00f6chte dein Projekt besprechen",
"email_lead_matched_heading": "Ein Anbieter möchte dein Projekt besprechen",
"email_lead_matched_greeting": "Hallo {first_name},",
"email_lead_matched_body": "Gute Nachrichten \u2014 ein verifizierter Anbieter wurde mit deinem Padel-Projekt abgeglichen. Er hat deine Projektbeschreibung und Kontaktdaten.",
"email_lead_matched_context": "Du hast eine Angebotsanfrage f\u00fcr eine {facility_type}-Anlage mit {court_count} Pl\u00e4tzen in {country} eingereicht.",
"email_lead_matched_next_heading": "Was passiert als N\u00e4chstes",
"email_lead_matched_next_body": "Der Anbieter hat deine Projektbeschreibung und Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 24\u201348 Stunden per E-Mail oder Telefon.",
"email_lead_matched_tip": "Tipp: Schnelles Reagieren auf Anbieter-Kontaktaufnahmen erh\u00f6ht deine Chance auf wettbewerbsf\u00e4hige Angebote.",
"email_lead_matched_btn": "Zum Dashboard \u2192",
"email_lead_matched_note": "Du erh\u00e4ltst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter deine Projektdetails freischaltet.",
"email_lead_matched_subject": "{first_name}, ein Anbieter m\u00f6chte dein Projekt besprechen",
"email_lead_matched_preheader": "Der Anbieter wird sich direkt bei dir melden \u2014 das erwartet dich",
"email_lead_matched_body": "Gute Neuigkeit — ein verifizierter Anbieter wurde mit Deinem Padel-Projekt abgeglichen. Er hat Dein Projektbriefing und Deine Kontaktdaten.",
"email_lead_matched_context": "Du hast eine Angebotsanfrage für eine {facility_type}-Anlage mit {court_count} Plätzen in {country} eingereicht.",
"email_lead_matched_next_heading": "Was passiert als Nächstes",
"email_lead_matched_next_body": "Der Anbieter hat Dein Projektbriefing und Deine Kontaktdaten erhalten. Die meisten Anbieter melden sich innerhalb von 2448 Stunden per E-Mail oder Telefon.",
"email_lead_matched_tip": "Tipp: Wer schnell auf Anbieter-Kontaktaufnahmen reagiert, erhöht seine Chancen auf wettbewerbsfähige Angebote.",
"email_lead_matched_btn": "Zum Dashboard ",
"email_lead_matched_note": "Du erhältst diese Benachrichtigung jedes Mal, wenn ein neuer Anbieter Deine Projektdetails freischaltet.",
"email_lead_matched_subject": "{first_name}, ein Anbieter möchte dein Projekt besprechen",
"email_lead_matched_preheader": "Der Anbieter meldet sich direkt bei Dir das erwartet Dich",
"email_enquiry_heading": "Neue Anfrage von {contact_name}",
"email_enquiry_body": "Du hast eine neue Anfrage \u00fcber deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
"email_enquiry_body": "Du hast eine neue Anfrage über deinen <strong>{supplier_name}</strong>-Verzeichniseintrag.",
"email_enquiry_lbl_from": "Von",
"email_enquiry_lbl_message": "Nachricht",
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden f\u00fcr den besten Eindruck.",
"email_enquiry_respond_fast": "Antworte innerhalb von 24 Stunden für den besten ersten Eindruck.",
"email_enquiry_reply": "Antworte direkt an <a href=\"mailto:{contact_email}\" style=\"color:#1D4ED8;\">{contact_email}</a>.",
"email_enquiry_subject": "Neue Anfrage von {contact_name} \u00fcber deinen Verzeichniseintrag",
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu treten",
"email_enquiry_subject": "Neue Anfrage von {contact_name} über deinen Verzeichniseintrag",
"email_enquiry_preheader": "Antworte, um mit diesem potenziellen Kunden in Kontakt zu kommen",
"email_business_plan_heading": "Dein Businessplan ist fertig",
"email_business_plan_body": "Dein Padel-Businessplan wurde als PDF erstellt und steht zum Download bereit.",
"email_business_plan_includes": "Dein Plan enth\u00e4lt Investitions\u00fcbersicht, Umsatzprognosen und Break-Even-Analyse.",
"email_business_plan_btn": "PDF herunterladen \u2192",
"email_business_plan_quote_cta": "Bereit f\u00fcr den n\u00e4chsten Schritt? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Angebote von Anbietern einholen \u2192</a>",
"email_business_plan_includes": "Dein Plan enthält Investitionsübersicht, Umsatzprognosen und Break-Even-Analyse.",
"email_business_plan_btn": "PDF herunterladen ",
"email_business_plan_quote_cta": "Bereit für den nächsten Schritt? <a href=\"{quote_url}\" style=\"color:#1D4ED8;font-weight:500;\">Angebote von Anbietern einholen </a>",
"email_business_plan_subject": "Dein Businessplan-PDF steht zum Download bereit",
"email_business_plan_preheader": "Professioneller Padel-Finanzplan \u2014 jetzt herunterladen",
"email_footer_tagline": "Die Planungsplattform f\u00fcr Padel-Unternehmer",
"email_footer_copyright": "\u00a9 {year} {app_name}. Du erh\u00e4ltst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast."
"email_business_plan_preheader": "Professioneller Padel-Finanzplan jetzt herunterladen",
"email_footer_tagline": "Die Planungsplattform für Padel-Unternehmer",
"email_footer_copyright": "© {year} {app_name}. Du erhältst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.",
"footer_market_score": "Market Score",
"mscore_page_title": "Der padelnomics Market Score — So messen wir Marktpotenzial",
"mscore_meta_desc": "Der padelnomics Market Score bewertet Städte von 0 bis 100 nach ihrem Potenzial für Padel-Investitionen. Erfahre, wie Demografie, Wirtschaftskraft, Nachfragesignale und Datenabdeckung einfließen.",
"mscore_og_desc": "Ein datengestützter Komposit-Score (0100), der die Attraktivität einer Stadt für Padelanlagen-Investitionen misst. Was steckt dahinter — und was bedeutet er für Deine Planung?",
"mscore_h1": "Der padelnomics Market Score",
"mscore_subtitle": "Ein datengestütztes Maß für die Attraktivität einer Stadt als Padel-Investitionsstandort.",
"mscore_what_h2": "Was der Score misst",
"mscore_what_intro": "Der Market Score ist ein Komposit-Index von 0 bis 100, der das Potenzial einer Stadt als Standort für Padelanlagen-Investitionen bewertet. Vier Datenkategorien fließen in eine einzige Kennzahl ein — damit Du schnell einschätzen kannst, welche Märkte sich genauer anzuschauen lohnen.",
"mscore_cat_demo_h3": "Demografie",
"mscore_cat_demo_p": "Bevölkerungsgröße als Indikator für den adressierbaren Markt. Größere Städte tragen in der Regel mehr Anlagen und höhere Auslastung.",
"mscore_cat_econ_h3": "Wirtschaftskraft",
"mscore_cat_econ_p": "Regionale Kaufkraft und Einkommensindikatoren. In Märkten mit höherem verfügbarem Einkommen ist die Nachfrage nach Freizeitsportarten wie Padel tendenziell stärker.",
"mscore_cat_demand_h3": "Nachfrageindikatoren",
"mscore_cat_demand_p": "Signale aus dem laufenden Betrieb bestehender Anlagen — Auslastungsraten, Buchungsdaten, Anzahl aktiver Standorte. Wo sich reale Nachfrage bereits messen lässt, ist das der stärkste Indikator.",
"mscore_cat_data_h3": "Datenqualität",
"mscore_cat_data_p": "Wie umfassend die Datenlage für eine Stadt ist. Ein Score auf Basis unvollständiger Daten ist weniger belastbar — wir machen das transparent, damit Du weißt, wo eigene Recherche sinnvoll ist.",
"mscore_read_h2": "Wie Du den Score liest",
"mscore_band_high_label": "70100: Starker Markt",
"mscore_band_high_p": "Große Bevölkerung, hohe Wirtschaftskraft und nachgewiesene Nachfrage durch bestehende Anlagen. Diese Städte haben validierte Padel-Märkte mit belastbaren Benchmarks für die Finanzplanung.",
"mscore_band_mid_label": "4569: Solides Mittelfeld",
"mscore_band_mid_p": "Gute Grundlagen mit Wachstumspotenzial. Genug Daten für fundierte Planung, aber weniger Wettbewerb als in den Top-Städten. Häufig der Sweet Spot für Neueinsteiger.",
"mscore_band_low_label": "Unter 45: Früher Markt",
"mscore_band_low_p": "Weniger validierte Daten oder kleinere Bevölkerung. Das heißt nicht, dass die Stadt unattraktiv ist — es kann weniger Wettbewerb und bessere Konditionen für Früheinsteiger bedeuten. Rechne mit mehr eigener Recherche vor Ort.",
"mscore_read_note": "Ein niedriger Score bedeutet nicht automatisch eine schlechte Investition. Er kann auf begrenzte Datenlage oder einen noch jungen Markt hinweisen — weniger Wettbewerb und günstigere Einstiegsbedingungen sind möglich.",
"mscore_sources_h2": "Datenquellen",
"mscore_sources_p": "Der Market Score basiert auf Daten europäischer Statistikämter (Bevölkerung und Wirtschaftsindikatoren), Buchungsplattformen für Padelanlagen (Standortanzahl, Preise, Auslastung) und geografischen Datenbanken (Standortdaten). Die Daten werden monatlich aktualisiert.",
"mscore_limits_h2": "Einschränkungen",
"mscore_limits_p1": "Der Score bildet die verfügbare Datenlage ab, nicht die absolute Marktwahrheit. Städte, in denen weniger Anlagen auf Buchungsplattformen erfasst sind, können bei den Nachfrageindikatoren niedrigere Werte zeigen — selbst wenn die lokale Nachfrage hoch ist.",
"mscore_limits_p2": "Der Score berücksichtigt keine lokalen Faktoren wie Immobilienkosten, Genehmigungszeiträume, Wettbewerbsdynamik oder regulatorische Rahmenbedingungen. Diese Aspekte sind entscheidend und erfordern Recherche vor Ort.",
"mscore_limits_p3": "Nutze den Market Score als Ausgangspunkt für die Priorisierung, nicht als finale Investitionsentscheidung. Im Finanzplaner kannst Du Dein konkretes Szenario durchrechnen.",
"mscore_cta_markets": "Stadtbewertungen ansehen",
"mscore_cta_planner": "Investment modellieren",
"mscore_faq_h2": "Häufig gestellte Fragen",
"mscore_faq_q1": "Was ist der padelnomics Market Score?",
"mscore_faq_a1": "Ein Komposit-Index von 0 bis 100, der die Attraktivität einer Stadt für Padelanlagen-Investitionen misst. Er kombiniert Demografie, Wirtschaftskraft, Nachfrageindikatoren und Datenqualität in einer vergleichbaren Kennzahl.",
"mscore_faq_q2": "Wie oft wird der Score aktualisiert?",
"mscore_faq_a2": "Monatlich. Neue Daten aus Statistikämtern, Buchungsplattformen und Standortdatenbanken werden regelmäßig extrahiert und verarbeitet. Der Score spiegelt immer die aktuellsten verfügbaren Daten wider.",
"mscore_faq_q3": "Warum hat meine Stadt einen niedrigen Score?",
"mscore_faq_a3": "Meist wegen begrenzter Datenabdeckung oder geringerer Bevölkerung. Ein niedriger Score bedeutet nicht, dass die Stadt unattraktiv ist — sondern dass uns weniger Daten zur Quantifizierung der Chance vorliegen. Eigene Recherche kann die Lücken schließen.",
"mscore_faq_q4": "Kann ich Scores länderübergreifend vergleichen?",
"mscore_faq_a4": "Ja. Die Methodik ist für alle Märkte einheitlich, sodass ein Score von 72 in Deutschland direkt vergleichbar ist mit einem 72 in Spanien oder Großbritannien.",
"mscore_faq_q5": "Garantiert ein hoher Score eine gute Investition?",
"mscore_faq_a5": "Nein. Der Score misst die Marktattraktivität auf Makroebene. Deine konkrete Investition hängt von Anlagentyp, Baukosten, Mietkonditionen und Dutzenden weiterer Faktoren ab. Im Finanzplaner kannst Du Dein Szenario mit echten Zahlen durchrechnen."
}

View File

@@ -1641,5 +1641,49 @@
"email_business_plan_preheader": "Professional padel facility financial plan \u2014 download now",
"email_footer_tagline": "The padel business planning platform",
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request."
"email_footer_copyright": "\u00a9 {year} {app_name}. You received this email because you have an account or submitted a request.",
"footer_market_score": "Market Score",
"mscore_page_title": "The padelnomics Market Score \u2014 How We Measure Market Potential",
"mscore_meta_desc": "The padelnomics Market Score rates cities from 0 to 100 on their potential for padel investment. Learn how demographics, economic strength, demand signals, and data coverage feed into the score.",
"mscore_og_desc": "A data-driven composite score (0\u2013100) that measures how attractive a city is for padel court investment. See what goes into it and what it means for your planning.",
"mscore_h1": "The padelnomics Market Score",
"mscore_subtitle": "A data-driven measure of how attractive a city is for padel investment.",
"mscore_what_h2": "What It Measures",
"mscore_what_intro": "The Market Score is a composite index from 0 to 100 that evaluates a city\u2019s potential as a location for padel court investment. It combines four categories of data into a single number designed to help you prioritize markets worth investigating further.",
"mscore_cat_demo_h3": "Demographics",
"mscore_cat_demo_p": "Population size as a proxy for the addressable market. Larger cities generally support more venues and higher utilization.",
"mscore_cat_econ_h3": "Economic Strength",
"mscore_cat_econ_p": "Regional purchasing power and income indicators. Markets where people have higher disposable income tend to sustain stronger demand for leisure sports like padel.",
"mscore_cat_demand_h3": "Demand Evidence",
"mscore_cat_demand_p": "Signals from existing venue activity \u2014 occupancy rates, booking data, and the number of operating venues. Where real demand is already measurable, it\u2019s the strongest indicator.",
"mscore_cat_data_h3": "Data Completeness",
"mscore_cat_data_p": "How much data we have for that city. A score influenced by incomplete data is less reliable \u2014 we surface this explicitly so you know when to dig deeper on your own.",
"mscore_read_h2": "How To Read the Score",
"mscore_band_high_label": "70\u2013100: Strong market",
"mscore_band_high_p": "Large population, economic power, and proven demand from existing venues. These cities have validated padel markets with reliable benchmarks for financial planning.",
"mscore_band_mid_label": "45\u201369: Solid mid-tier",
"mscore_band_mid_p": "Good fundamentals with room for growth. Enough data to plan with confidence, but less competition than top-tier cities. Often the sweet spot for new entrants.",
"mscore_band_low_label": "Below 45: Early-stage market",
"mscore_band_low_p": "Less validated data or smaller populations. This does not mean a city is a bad investment \u2014 it may mean less competition and first-mover advantage. Expect to do more local research.",
"mscore_read_note": "A lower score does not mean a city is a bad investment. It may indicate less available data or a market still developing \u2014 which can mean less competition and better terms for early entrants.",
"mscore_sources_h2": "Data Sources",
"mscore_sources_p": "The Market Score draws on data from European statistical offices (population and economic indicators), court booking platforms (venue counts, pricing, occupancy), and geographic databases (venue locations). Data is refreshed monthly as new extractions run.",
"mscore_limits_h2": "Limitations",
"mscore_limits_p1": "The score reflects available data, not absolute market truth. Cities where fewer venues are tracked on booking platforms may score lower on demand evidence \u2014 even if local demand is strong.",
"mscore_limits_p2": "The score does not account for local factors like real estate costs, permitting timelines, competitive dynamics, or regulatory environment. These matter enormously and require on-the-ground research.",
"mscore_limits_p3": "Use the Market Score as a starting point for prioritization, not a final investment decision. The financial planner is where you model your specific scenario.",
"mscore_cta_markets": "Browse city scores",
"mscore_cta_planner": "Model your investment",
"mscore_faq_h2": "Frequently Asked Questions",
"mscore_faq_q1": "What is the padelnomics Market Score?",
"mscore_faq_a1": "A composite index from 0 to 100 that measures how attractive a city is for padel court investment. It combines demographics, economic strength, demand evidence, and data completeness into a single comparable number.",
"mscore_faq_q2": "How often is the score updated?",
"mscore_faq_a2": "Monthly. New data from statistical offices, booking platforms, and venue databases is extracted and processed on a regular cycle. Scores reflect the most recent available data.",
"mscore_faq_q3": "Why is my city\u2019s score low?",
"mscore_faq_a3": "Usually because of limited data coverage or smaller population. A low score doesn\u2019t mean the city is unattractive \u2014 it means we have less data to quantify the opportunity. Local research can fill the gaps.",
"mscore_faq_q4": "Can I compare scores across countries?",
"mscore_faq_a4": "Yes. The methodology is consistent across all markets we track, so a score of 72 in Germany is directly comparable to a 72 in Spain or the UK.",
"mscore_faq_q5": "Does a high score guarantee a good investment?",
"mscore_faq_a5": "No. The score measures market attractiveness at a macro level. Your specific investment depends on venue type, build costs, lease terms, and dozens of other factors. Use the financial planner to model your scenario with real numbers."
}

View File

@@ -34,12 +34,15 @@ Design decisions
"""
import importlib
import logging
import os
import re
import sqlite3
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from dotenv import load_dotenv
@@ -89,7 +92,7 @@ def migrate(db_path=None):
if pending:
for name in pending:
print(f" Applying {name}...")
logger.info("Applying %s...", name)
mod = importlib.import_module(
f"padelnomics.migrations.versions.{name}"
)
@@ -98,9 +101,9 @@ def migrate(db_path=None):
"INSERT INTO _migrations (name) VALUES (?)", (name,)
)
conn.commit()
print(f"Applied {len(pending)} migration(s): {db_path}")
logger.info("Applied %s migration(s): %s", len(pending), db_path)
else:
print(f"All migrations already applied: {db_path}")
logger.info("All migrations already applied: %s", db_path)
# Show tables (excluding internal sqlite/fts tables)
cursor = conn.execute(
@@ -109,10 +112,11 @@ def migrate(db_path=None):
" ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
print(f" Tables: {', '.join(tables)}")
logger.info("Tables: %s", ", ".join(tables))
conn.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
migrate()

View File

@@ -0,0 +1,81 @@
"""Change articles unique constraint from url_path alone to (url_path, language).
Previously url_path was declared UNIQUE, which prevented multiple languages
from sharing the same url_path (e.g. /markets/germany/berlin for both de and en).
"""
def up(conn) -> None:
# ── 1. Drop FTS triggers + virtual table ──────────────────────────────────
conn.execute("DROP TRIGGER IF EXISTS articles_ai")
conn.execute("DROP TRIGGER IF EXISTS articles_ad")
conn.execute("DROP TRIGGER IF EXISTS articles_au")
conn.execute("DROP TABLE IF EXISTS articles_fts")
# ── 2. Recreate articles with UNIQUE(url_path, language) ──────────────────
conn.execute("""
CREATE TABLE articles_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_slug TEXT,
language TEXT NOT NULL DEFAULT 'en',
date_modified TEXT,
seo_head TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT,
UNIQUE(url_path, language)
)
""")
conn.execute("""
INSERT INTO articles_new
(id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, template_slug, language,
date_modified, seo_head, created_at, updated_at)
SELECT id, url_path, slug, title, meta_description, country, region,
og_image_url, status, published_at, template_slug, language,
date_modified, seo_head, created_at, updated_at
FROM articles
""")
conn.execute("DROP TABLE articles")
conn.execute("ALTER TABLE articles_new RENAME TO articles")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_lang ON articles(url_path, language)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
# ── 3. Recreate FTS + triggers ─────────────────────────────────────────────
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
)
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")

View File

@@ -3,10 +3,12 @@ Planner domain: padel court financial planner + scenario management.
"""
import json
import logging
import math
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
from quart import Blueprint, Response, g, jsonify, render_template, request
from ..auth.routes import login_required
@@ -18,6 +20,7 @@ from ..core import (
fetch_all,
fetch_one,
get_paddle_price,
utcnow_iso,
)
from ..i18n import get_translations
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
@@ -502,7 +505,7 @@ async def save_scenario():
location = form.get("location", "")
scenario_id = form.get("scenario_id")
now = datetime.utcnow().isoformat()
now = utcnow_iso()
is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0
@@ -533,7 +536,7 @@ async def save_scenario():
}
)
except Exception as e:
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
logger.warning("Failed to add %s to nurture audience: %s", g.user["email"], e)
lang = g.get("lang", "en")
t = get_translations(lang)
@@ -563,7 +566,7 @@ async def get_scenario(scenario_id: int):
@login_required
@csrf_protect
async def delete_scenario(scenario_id: int):
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(now, scenario_id, g.user["id"]),

View File

@@ -71,6 +71,7 @@
<button type="submit" class="btn" style="width:100%;margin-top:0.5rem" id="export-buy-btn">
{{ t.export_btn }}
</button>
<div id="export-error" style="display:none;margin-top:0.75rem;padding:0.75rem 1rem;background:#FEE2E2;color:#991B1B;border-radius:8px;font-size:0.875rem"></div>
</form>
</div>
@@ -106,9 +107,16 @@
{% block scripts %}
<script>
function showExportError(msg) {
var el = document.getElementById('export-error');
el.textContent = msg;
el.style.display = 'block';
}
document.getElementById('export-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = document.getElementById('export-buy-btn');
document.getElementById('export-error').style.display = 'none';
btn.disabled = true;
btn.textContent = '{{ t.export_generating }}';
@@ -120,7 +128,7 @@ document.getElementById('export-form').addEventListener('submit', async function
});
const data = await resp.json();
if (data.error) {
alert(data.error);
showExportError(data.error);
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
return;
@@ -133,7 +141,7 @@ document.getElementById('export-form').addEventListener('submit', async function
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
} catch (err) {
alert('{{ t.export_failed }}');
showExportError('{{ t.export_failed }}');
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
}

View File

@@ -59,6 +59,11 @@ async def about():
return await render_template("about.html")
@bp.route("/market-score")
async def market_score():
return await render_template("market_score.html")
@bp.route("/imprint")
async def imprint():
lang = g.get("lang", "en")

View File

@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block title %}{{ t.mscore_page_title }}{% endblock %}
{% block head %}
<meta name="description" content="{{ t.mscore_meta_desc }}">
<meta property="og:title" content="{{ t.mscore_page_title }}">
<meta property="og:description" content="{{ t.mscore_og_desc }}">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"name": "{{ t.mscore_page_title }}",
"description": "{{ t.mscore_meta_desc }}",
"url": "{{ config.BASE_URL }}/{{ lang }}/market-score",
"inLanguage": "{{ lang }}",
"isPartOf": {
"@type": "WebSite",
"name": "Padelnomics",
"url": "{{ config.BASE_URL }}"
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
{"@type": "ListItem", "position": 2, "name": "Market Score", "item": "{{ config.BASE_URL }}/{{ lang }}/market-score"}
]
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "{{ t.mscore_faq_q1 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a1 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q2 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a2 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q3 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a3 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q4 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a4 }}"}
},
{
"@type": "Question",
"name": "{{ t.mscore_faq_q5 }}",
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a5 }}"}
}
]
}
]
}
</script>
{% endblock %}
{% block content %}
<main class="container-page py-12">
<div class="max-w-3xl mx-auto">
<!-- Hero -->
<header class="text-center mb-12">
<h1 class="text-3xl mb-2">
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
Market Score
</h1>
<p class="text-lg text-slate">{{ t.mscore_subtitle }}</p>
</header>
<!-- What It Measures -->
<section class="mb-10">
<h2 class="text-xl mb-4">{{ t.mscore_what_h2 }}</h2>
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_what_intro }}</p>
<div class="grid-2">
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f465;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_demo_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_demo_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f4b6;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_econ_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_econ_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f4c8;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_demand_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_demand_p }}</p>
</div>
<div class="card">
<div style="font-size:1.5rem;margin-bottom:0.5rem">&#x1f50d;</div>
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_data_h3 }}</h3>
<p class="text-sm text-slate-dark">{{ t.mscore_cat_data_p }}</p>
</div>
</div>
</section>
<!-- How To Read the Score -->
<section class="card mb-8">
<h2 class="text-xl mb-4">{{ t.mscore_read_h2 }}</h2>
<div class="space-y-4">
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#16A34A;flex-shrink:0"></span>
<span class="font-semibold text-navy">{{ t.mscore_band_high_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_high_p }}</p>
</div>
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#D97706;flex-shrink:0"></span>
<span class="font-semibold text-navy">{{ t.mscore_band_mid_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_mid_p }}</p>
</div>
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#64748B;flex-shrink:0"></span>
<span class="font-semibold text-navy">{{ t.mscore_band_low_label }}</span>
</div>
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_low_p }}</p>
</div>
</div>
<p class="text-sm text-slate mt-4" style="border-left:3px solid #E2E8F0;padding-left:0.75rem">{{ t.mscore_read_note }}</p>
</section>
<!-- Data Sources -->
<section class="card mb-8">
<h2 class="text-xl mb-4">{{ t.mscore_sources_h2 }}</h2>
<p class="text-slate-dark leading-relaxed">{{ t.mscore_sources_p }}</p>
</section>
<!-- Limitations -->
<section class="card mb-8">
<h2 class="text-xl mb-4">{{ t.mscore_limits_h2 }}</h2>
<div class="space-y-3 text-slate-dark leading-relaxed">
<p>{{ t.mscore_limits_p1 }}</p>
<p>{{ t.mscore_limits_p2 }}</p>
<p>{{ t.mscore_limits_p3 }}</p>
</div>
</section>
<!-- CTA -->
<div class="text-center my-12">
<a href="{{ url_for('content.markets') }}" class="btn" style="margin-right:0.75rem">{{ t.mscore_cta_markets }}</a>
<a href="{{ url_for('planner.index') }}" class="btn-secondary" style="display:inline-block;padding:0.625rem 1.25rem;border-radius:6px;font-weight:600;font-size:0.875rem;text-decoration:none">{{ t.mscore_cta_planner }}</a>
</div>
<!-- FAQ -->
<section>
<h2 class="text-xl mb-4">{{ t.mscore_faq_h2 }}</h2>
<div class="space-y-4">
{% for i in range(1, 6) %}
<details style="border:1px solid #E2E8F0;border-radius:8px;padding:0.75rem 1rem">
<summary class="font-semibold text-navy" style="cursor:pointer">{{ t['mscore_faq_q' ~ i] }}</summary>
<p class="text-sm text-slate-dark mt-2">{{ t['mscore_faq_a' ~ i] }}</p>
</details>
{% endfor %}
</div>
</section>
</div>
</main>
{% endblock %}

View File

@@ -34,12 +34,15 @@ Fields mapped (DuckDB → data_json camelCase key):
import argparse
import json
import logging
import os
import sqlite3
from pathlib import Path
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
@@ -67,13 +70,13 @@ def _load_analytics(city_slugs: list[str]) -> dict[str, dict]:
"""
path = Path(DUCKDB_PATH)
if not path.exists():
print(f" [analytics] DuckDB not found at {path} — skipping analytics refresh.")
logger.warning("DuckDB not found at %s — skipping analytics refresh.", path)
return {}
try:
import duckdb
except ImportError:
print(" [analytics] duckdb not installed — skipping analytics refresh.")
logger.warning("duckdb not installed — skipping analytics refresh.")
return {}
result: dict[str, dict] = {}
@@ -98,7 +101,7 @@ def _load_analytics(city_slugs: list[str]) -> dict[str, dict]:
result[slug] = overrides
except Exception as exc:
print(f" [analytics] DuckDB query failed: {exc}")
logger.error("DuckDB query failed: %s", exc)
return result
@@ -124,13 +127,13 @@ def refresh(dry_run: bool = False) -> int:
city_slug_to_ids.setdefault(slug, []).append(row["id"])
if not city_slug_to_ids:
print("No template_data rows with city_slug found.")
logger.info("No template_data rows with city_slug found.")
conn.close()
return 0
analytics = _load_analytics(list(city_slug_to_ids.keys()))
if not analytics:
print("No analytics data found — nothing to update.")
logger.info("No analytics data found — nothing to update.")
conn.close()
return 0
@@ -154,13 +157,13 @@ def refresh(dry_run: bool = False) -> int:
data.update(overrides)
if dry_run:
print(f" [dry-run] id={row_id} city_slug={slug}: {changed}")
logger.info("[dry-run] id=%s city_slug=%s: %s", row_id, slug, changed)
else:
conn.execute(
"UPDATE template_data SET data_json = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(data), row_id),
)
print(f" Updated id={row_id} city_slug={slug}: {list(changed.keys())}")
logger.info("Updated id=%s city_slug=%s: %s", row_id, slug, list(changed.keys()))
updated += 1
if not dry_run:
@@ -184,7 +187,7 @@ def _trigger_generation() -> None:
headers={"X-Admin-Key": admin_key},
)
with urllib.request.urlopen(req, timeout=120) as resp:
print(f" Generation triggered: HTTP {resp.status}")
logger.info("Generation triggered: HTTP %s", resp.status)
def main() -> None:
@@ -195,14 +198,17 @@ def main() -> None:
help="Trigger article re-generation after updating")
args = parser.parse_args()
print(f"{'[DRY RUN] ' if args.dry_run else ''}Refreshing template_data from DuckDB…")
prefix = "[DRY RUN] " if args.dry_run else ""
logger.info("%sRefreshing template_data from DuckDB...", prefix)
count = refresh(dry_run=args.dry_run)
print(f"{'Would update' if args.dry_run else 'Updated'} {count} rows.")
action = "Would update" if args.dry_run else "Updated"
logger.info("%s %s rows.", action, count)
if args.generate and count > 0 and not args.dry_run:
print("Triggering article generation")
logger.info("Triggering article generation...")
_trigger_generation()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

View File

@@ -15,14 +15,17 @@ Usage:
import asyncio
import json
import logging
import os
import sqlite3
import sys
from datetime import date, timedelta
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
@@ -1363,7 +1366,7 @@ def seed_templates(conn: sqlite3.Connection) -> dict[str, int]:
).fetchone()
if existing:
print(f" Template '{tmpl['slug']}' already exists (id={existing[0]}), skipping.")
logger.info(" Template '%s' already exists (id=%s), skipping.", tmpl["slug"], existing[0])
template_ids[tmpl["slug"]] = existing[0]
else:
cur = conn.execute(
@@ -1383,14 +1386,14 @@ def seed_templates(conn: sqlite3.Connection) -> dict[str, int]:
),
)
template_ids[tmpl["slug"]] = cur.lastrowid
print(f" Created template '{tmpl['slug']}' (id={cur.lastrowid})")
logger.info(" Created template '%s' (id=%s)", tmpl["slug"], cur.lastrowid)
return template_ids
def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> int:
"""Insert template_data rows for all cities × languages. Returns count inserted."""
now = __import__("datetime").datetime.utcnow().isoformat()
now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
inserted = 0
en_id = template_ids.get("city-padel-cost-en")
@@ -1411,7 +1414,7 @@ def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> in
).fetchone()
if existing:
print(f" Data row '{city_slug}' ({lang}) already exists, skipping.")
logger.info(" Data row '%s' (%s) already exists, skipping.", city_slug, lang)
else:
conn.execute(
"""INSERT INTO template_data (template_id, data_json, created_at)
@@ -1419,7 +1422,7 @@ def seed_data_rows(conn: sqlite3.Connection, template_ids: dict[str, int]) -> in
(tmpl_id, data_json, now),
)
inserted += 1
print(f" Inserted data row '{city_slug}' ({lang})")
logger.info(" Inserted data row '%s' (%s)", city_slug, lang)
return inserted
@@ -1432,7 +1435,7 @@ async def generate_articles(template_ids: dict[str, int]) -> None:
from padelnomics.admin.routes import _generate_from_template # noqa: PLC0415
from padelnomics.core import close_db, fetch_one, init_db
print("\nInitialising database connection...")
logger.info("Initialising database connection...")
await init_db(DATABASE_PATH)
start_date = date.today() - timedelta(days=30) # backdate so all are immediately live
@@ -1441,9 +1444,9 @@ async def generate_articles(template_ids: dict[str, int]) -> None:
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (tmpl_id,))
assert template is not None, f"Template '{slug}' not found in DB"
print(f"\nGenerating articles for template '{slug}'...")
logger.info("Generating articles for template '%s'...", slug)
count = await _generate_from_template(template, start_date, articles_per_day=3)
print(f" Generated {count} articles.")
logger.info(" Generated %s articles.", count)
await close_db()
@@ -1463,28 +1466,29 @@ def main() -> None:
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
print("Seeding article templates...")
logger.info("Seeding article templates...")
template_ids = seed_templates(conn)
print("\nSeeding city data rows...")
logger.info("Seeding city data rows...")
inserted = seed_data_rows(conn, template_ids)
conn.commit()
conn.close()
print(f"\nDone. {inserted} data rows inserted.")
print("Templates and data rows are visible in admin Templates.")
logger.info("Done. %s data rows inserted.", inserted)
logger.info("Templates and data rows are visible in admin -> Templates.")
if "--generate" in sys.argv:
print("\nRunning article generation pipeline...")
logger.info("Running article generation pipeline...")
asyncio.run(generate_articles(template_ids))
print("\nGeneration complete. Check admin Articles.")
logger.info("Generation complete. Check admin -> Articles.")
else:
print(
"\nTo generate articles, either:\n"
logger.info(
"To generate articles, either:\n"
" 1. Run: uv run python -m padelnomics.scripts.seed_content --generate\n"
" 2. Or visit admin Templates (template) Generate"
" 2. Or visit admin -> Templates -> (template) -> Generate"
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

View File

@@ -7,14 +7,17 @@ Usage:
uv run python -m padelnomics.scripts.seed_dev_data
"""
import logging
import os
import sqlite3
import sys
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from pathlib import Path
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
load_dotenv()
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
@@ -284,7 +287,7 @@ LEADS = [
def main():
db_path = DATABASE_PATH
if not Path(db_path).exists():
print(f"ERROR: Database not found at {db_path}. Run migrations first.")
logger.error("Database not found at %s. Run migrations first.", db_path)
sys.exit(1)
conn = sqlite3.connect(db_path)
@@ -292,37 +295,37 @@ def main():
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
now = datetime.utcnow()
now = datetime.now(UTC)
# 1. Create dev user
print("Creating dev user (dev@localhost)...")
logger.info("Creating dev user (dev@localhost)...")
existing = conn.execute("SELECT id FROM users WHERE email = 'dev@localhost'").fetchone()
if existing:
dev_user_id = existing["id"]
print(f" Already exists (id={dev_user_id})")
logger.info(" Already exists (id=%s)", dev_user_id)
else:
cursor = conn.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("dev@localhost", "Dev User", now.isoformat()),
("dev@localhost", "Dev User", now.strftime("%Y-%m-%d %H:%M:%S")),
)
dev_user_id = cursor.lastrowid
print(f" Created (id={dev_user_id})")
logger.info(" Created (id=%s)", dev_user_id)
# Grant admin role to dev user
conn.execute(
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, 'admin')",
(dev_user_id,),
)
print(" Admin role granted")
logger.info(" Admin role granted")
# 2. Seed suppliers
print(f"\nSeeding {len(SUPPLIERS)} suppliers...")
logger.info("Seeding %s suppliers...", len(SUPPLIERS))
supplier_ids = {}
for s in SUPPLIERS:
existing = conn.execute("SELECT id FROM suppliers WHERE slug = ?", (s["slug"],)).fetchone()
if existing:
supplier_ids[s["slug"]] = existing["id"]
print(f" {s['name']} already exists (id={existing['id']})")
logger.info(" %s already exists (id=%s)", s["name"], existing["id"])
continue
cursor = conn.execute(
@@ -336,20 +339,20 @@ def main():
s["website"], s["description"], s["category"], s["tier"],
s["credit_balance"], s["monthly_credits"], s["contact_name"],
s["contact_email"], s["years_in_business"], s["project_count"],
s["service_area"], now.isoformat(),
s["service_area"], now.strftime("%Y-%m-%d %H:%M:%S"),
),
)
supplier_ids[s["slug"]] = cursor.lastrowid
print(f" {s['name']} -> id={cursor.lastrowid}")
logger.info(" %s -> id=%s", s["name"], cursor.lastrowid)
# 3. Claim paid suppliers — each gets its own owner user + subscription
print("\nClaiming paid suppliers with owner accounts...")
logger.info("Claiming paid suppliers with owner accounts...")
claimed_suppliers = [
("padeltech-gmbh", "supplier_pro", "hans@padeltech.example.com", "Hans Weber"),
("courtbuild-spain", "supplier_growth", "maria@courtbuild.example.com", "Maria Garcia"),
("desert-padel-fze", "supplier_pro", "ahmed@desertpadel.example.com", "Ahmed Al-Rashid"),
]
period_end = (now + timedelta(days=30)).isoformat()
period_end = (now + timedelta(days=30)).strftime("%Y-%m-%d %H:%M:%S")
for slug, plan, email, name in claimed_suppliers:
sid = supplier_ids.get(slug)
if not sid:
@@ -364,14 +367,14 @@ def main():
else:
cursor = conn.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
(email, name, now.isoformat()),
(email, name, now.strftime("%Y-%m-%d %H:%M:%S")),
)
owner_id = cursor.lastrowid
# Claim the supplier
conn.execute(
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
(owner_id, now.isoformat(), sid),
(owner_id, now.strftime("%Y-%m-%d %H:%M:%S"), sid),
)
# Create billing customer record
@@ -382,7 +385,7 @@ def main():
conn.execute(
"""INSERT INTO billing_customers (user_id, provider_customer_id, created_at)
VALUES (?, ?, ?)""",
(owner_id, f"ctm_dev_{slug}", now.isoformat()),
(owner_id, f"ctm_dev_{slug}", now.strftime("%Y-%m-%d %H:%M:%S")),
)
# Create active subscription
@@ -396,12 +399,12 @@ def main():
current_period_end, created_at)
VALUES (?, ?, 'active', ?, ?, ?)""",
(owner_id, plan, f"sub_dev_{slug}",
period_end, now.isoformat()),
period_end, now.strftime("%Y-%m-%d %H:%M:%S")),
)
print(f" {slug} -> owner {email} ({plan})")
logger.info(" %s -> owner %s (%s)", slug, email, plan)
# 4. Seed leads
print(f"\nSeeding {len(LEADS)} leads...")
logger.info("Seeding %s leads...", len(LEADS))
lead_ids = []
for i, lead in enumerate(LEADS):
from padelnomics.credits import HEAT_CREDIT_COSTS
@@ -426,10 +429,10 @@ def main():
),
)
lead_ids.append(cursor.lastrowid)
print(f" Lead #{cursor.lastrowid}: {lead['contact_name']} ({lead['heat_score']}, {lead['country']})")
logger.info(" Lead #%s: %s (%s, %s)", cursor.lastrowid, lead["contact_name"], lead["heat_score"], lead["country"])
# 5. Add credit ledger entries for claimed suppliers
print("\nAdding credit ledger entries...")
logger.info("Adding credit ledger entries...")
for slug in ("padeltech-gmbh", "courtbuild-spain", "desert-padel-fze"):
sid = supplier_ids.get(slug)
if not sid:
@@ -448,10 +451,10 @@ def main():
VALUES (?, ?, ?, 'admin_adjustment', 'Welcome bonus', ?)""",
(sid, 10, monthly + 10, (now - timedelta(days=25)).isoformat()),
)
print(f" {slug}: 2 ledger entries")
logger.info(" %s: 2 ledger entries", slug)
# 6. Add lead forwards for testing
print("\nAdding lead forwards...")
logger.info("Adding lead forwards...")
padeltech_id = supplier_ids.get("padeltech-gmbh")
if padeltech_id and len(lead_ids) >= 2:
for lead_id in lead_ids[:2]:
@@ -476,15 +479,16 @@ def main():
(padeltech_id, 80, lead_id, f"Unlocked lead #{lead_id}",
(now - timedelta(hours=6)).isoformat()),
)
print(f" PadelTech unlocked lead #{lead_id}")
logger.info(" PadelTech unlocked lead #%s", lead_id)
conn.commit()
conn.close()
print(f"\nDone! Seed data written to {db_path}")
print(" Login: /auth/dev-login?email=dev@localhost")
print(" Admin: set ADMIN_EMAILS=dev@localhost in .env, then dev-login grants admin role")
logger.info("Done! Seed data written to %s", db_path)
logger.info(" Login: /auth/dev-login?email=dev@localhost")
logger.info(" Admin: set ADMIN_EMAILS=dev@localhost in .env, then dev-login grants admin role")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

View File

@@ -6,6 +6,7 @@ Commands:
uv run python -m padelnomics.scripts.setup_paddle --sync # re-populate DB from existing Paddle products
"""
import logging
import os
import re
import sqlite3
@@ -13,6 +14,8 @@ import sys
from pathlib import Path
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options
from paddle_billing.Entities.Events.EventTypeName import EventTypeName
@@ -33,7 +36,8 @@ DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
if not PADDLE_API_KEY:
print("ERROR: Set PADDLE_API_KEY in .env first")
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
logger.error("Set PADDLE_API_KEY in .env first")
sys.exit(1)
@@ -202,7 +206,7 @@ _PRODUCT_BY_NAME = {p["name"]: p for p in PRODUCTS}
def _open_db():
db_path = DATABASE_PATH
if not Path(db_path).exists():
print(f"ERROR: Database not found at {db_path}. Run migrations first.")
logger.error("Database not found at %s. Run migrations first.", db_path)
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
@@ -221,7 +225,7 @@ def _write_product(conn, key, product_id, price_id, name, price_cents, billing_t
def sync(paddle, conn):
"""Fetch existing products from Paddle and re-populate paddle_products table."""
print(f"Syncing products from Paddle ({PADDLE_ENVIRONMENT})...\n")
logger.info("Syncing products from Paddle (%s)...", PADDLE_ENVIRONMENT)
products = paddle.products.list(ListProducts(includes=[Includes.Prices]))
@@ -231,7 +235,7 @@ def sync(paddle, conn):
if not spec:
continue
if not product.prices or len(product.prices) == 0:
print(f" SKIP {spec['key']}: no prices on {product.id}")
logger.warning(" SKIP %s: no prices on %s", spec["key"], product.id)
continue
# Use the first active price
@@ -241,26 +245,26 @@ def sync(paddle, conn):
spec["name"], spec["price"], spec["billing_type"],
)
matched += 1
print(f" {spec['key']}: {product.id} / {price.id}")
logger.info(" %s: %s / %s", spec["key"], product.id, price.id)
conn.commit()
if matched == 0:
print("\nNo matching products found in Paddle. Run without --sync first.")
logger.warning("No matching products found in Paddle. Run without --sync first.")
else:
print(f"\n{matched}/{len(PRODUCTS)} products synced to DB")
logger.info("%s/%s products synced to DB", matched, len(PRODUCTS))
def create(paddle, conn):
"""Create new products and prices in Paddle, write to DB, set up webhook."""
print(f"Creating products in {PADDLE_ENVIRONMENT}...\n")
logger.info("Creating products in %s...", PADDLE_ENVIRONMENT)
for spec in PRODUCTS:
product = paddle.products.create(CreateProduct(
name=spec["name"],
tax_category=TaxCategory.Standard,
))
print(f" Product: {spec['name']} -> {product.id}")
logger.info(" Product: %s -> %s", spec["name"], product.id)
price_kwargs = {
"description": spec["name"],
@@ -276,7 +280,7 @@ def create(paddle, conn):
price_kwargs["billing_cycle"] = Duration(interval=Interval.Month, frequency=1)
price = paddle.prices.create(CreatePrice(**price_kwargs))
print(f" Price: {spec['key']} = {price.id}")
logger.info(" Price: %s = %s", spec["key"], price.id)
_write_product(
conn, spec["key"], product.id, price.id,
@@ -284,7 +288,7 @@ def create(paddle, conn):
)
conn.commit()
print("\nAll products written to DB")
logger.info("All products written to DB")
# -- Notification destination (webhook) -----------------------------------
@@ -298,8 +302,8 @@ def create(paddle, conn):
EventTypeName.TransactionCompleted,
]
print("\nCreating webhook notification destination...")
print(f" URL: {webhook_url}")
logger.info("Creating webhook notification destination...")
logger.info(" URL: %s", webhook_url)
notification_setting = paddle.notification_settings.create(
CreateNotificationSetting(
@@ -313,8 +317,8 @@ def create(paddle, conn):
)
webhook_secret = notification_setting.endpoint_secret_key
print(f" ID: {notification_setting.id}")
print(f" Secret: {webhook_secret}")
logger.info(" ID: %s", notification_setting.id)
logger.info(" Secret: %s", webhook_secret)
env_path = Path(".env")
env_vars = {
@@ -331,13 +335,13 @@ def create(paddle, conn):
else:
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
env_path.write_text(env_text)
print("\nPADDLE_WEBHOOK_SECRET and PADDLE_NOTIFICATION_SETTING_ID written to .env")
logger.info("PADDLE_WEBHOOK_SECRET and PADDLE_NOTIFICATION_SETTING_ID written to .env")
else:
print("\n Add to .env:")
logger.info("Add to .env:")
for key, value in env_vars.items():
print(f" {key}={value}")
logger.info(" %s=%s", key, value)
print("\nDone. dev_run.sh will start ngrok and update the webhook URL automatically.")
logger.info("Done. dev_run.sh will start ngrok and update the webhook URL automatically.")
def main():
@@ -355,4 +359,5 @@ def main():
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
main()

View File

@@ -3,12 +3,12 @@
Uses an API key for auth. Fetches query stats and page stats.
"""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from urllib.parse import urlparse
import httpx
from ..core import config, execute
from ..core import config, execute, utcnow, utcnow_iso
_TIMEOUT_SECONDS = 30
@@ -27,7 +27,7 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS)
if not config.BING_WEBMASTER_API_KEY or not config.BING_SITE_URL:
return 0 # Bing not configured — skip silently
started_at = datetime.utcnow()
started_at = utcnow()
try:
rows_synced = 0
@@ -48,14 +48,14 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS)
if not isinstance(entries, list):
entries = []
cutoff = datetime.utcnow() - timedelta(days=days_back)
cutoff = utcnow() - timedelta(days=days_back)
for entry in entries:
# Bing date format: "/Date(1708905600000)/" (ms since epoch)
date_str = entry.get("Date", "")
if "/Date(" in date_str:
ms = int(date_str.split("(")[1].split(")")[0])
entry_date = datetime.utcfromtimestamp(ms / 1000)
entry_date = datetime.fromtimestamp(ms / 1000, tz=UTC)
else:
continue
@@ -99,7 +99,7 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS)
date_str = entry.get("Date", "")
if "/Date(" in date_str:
ms = int(date_str.split("(")[1].split(")")[0])
entry_date = datetime.utcfromtimestamp(ms / 1000)
entry_date = datetime.fromtimestamp(ms / 1000, tz=UTC)
else:
continue
@@ -122,21 +122,21 @@ async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS)
)
rows_synced += 1
duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
duration_ms = int((utcnow() - started_at).total_seconds() * 1000)
await execute(
"""INSERT INTO seo_sync_log
(source, status, rows_synced, started_at, completed_at, duration_ms)
VALUES ('bing', 'success', ?, ?, ?, ?)""",
(rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
(rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms),
)
return rows_synced
except Exception as exc:
duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
duration_ms = int((utcnow() - started_at).total_seconds() * 1000)
await execute(
"""INSERT INTO seo_sync_log
(source, status, rows_synced, error, started_at, completed_at, duration_ms)
VALUES ('bing', 'failed', 0, ?, ?, ?, ?)""",
(str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
(str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms),
)
raise

View File

@@ -5,11 +5,11 @@ is synchronous, so sync runs in asyncio.to_thread().
"""
import asyncio
from datetime import datetime, timedelta
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlparse
from ..core import config, execute
from ..core import config, execute, utcnow, utcnow_iso
# GSC returns max 25K rows per request
_ROWS_PER_PAGE = 25_000
@@ -95,11 +95,11 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int:
if not config.GSC_SERVICE_ACCOUNT_PATH or not config.GSC_SITE_URL:
return 0 # GSC not configured — skip silently
started_at = datetime.utcnow()
started_at = utcnow()
# GSC has ~2 day delay; fetch from days_back ago to 2 days ago
end_date = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d")
start_date = (datetime.utcnow() - timedelta(days=days_back + 2)).strftime("%Y-%m-%d")
end_date = (utcnow() - timedelta(days=2)).strftime("%Y-%m-%d")
start_date = (utcnow() - timedelta(days=days_back + 2)).strftime("%Y-%m-%d")
try:
rows = await asyncio.to_thread(
@@ -122,21 +122,21 @@ async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int:
)
rows_synced += 1
duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
duration_ms = int((utcnow() - started_at).total_seconds() * 1000)
await execute(
"""INSERT INTO seo_sync_log
(source, status, rows_synced, started_at, completed_at, duration_ms)
VALUES ('gsc', 'success', ?, ?, ?, ?)""",
(rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
(rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms),
)
return rows_synced
except Exception as exc:
duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
duration_ms = int((utcnow() - started_at).total_seconds() * 1000)
await execute(
"""INSERT INTO seo_sync_log
(source, status, rows_synced, error, started_at, completed_at, duration_ms)
VALUES ('gsc', 'failed', 0, ?, ?, ?, ?)""",
(str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
(str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms),
)
raise

View File

@@ -4,14 +4,14 @@ All heavy lifting happens in SQL. Functions accept filter parameters
and return plain dicts/lists.
"""
from datetime import datetime, timedelta
from datetime import timedelta
from ..core import execute, fetch_all, fetch_one
from ..core import execute, fetch_all, fetch_one, utcnow
def _date_cutoff(date_range_days: int) -> str:
"""Return ISO date string for N days ago."""
return (datetime.utcnow() - timedelta(days=date_range_days)).strftime("%Y-%m-%d")
return (utcnow() - timedelta(days=date_range_days)).strftime("%Y-%m-%d")
async def get_search_performance(

View File

@@ -4,11 +4,11 @@ Uses bearer token auth. Self-hosted instance, no rate limits.
Config already exists: UMAMI_API_URL, UMAMI_API_TOKEN, UMAMI_WEBSITE_ID.
"""
from datetime import datetime, timedelta
from datetime import timedelta
import httpx
from ..core import config, execute
from ..core import config, execute, utcnow, utcnow_iso
_TIMEOUT_SECONDS = 15
@@ -21,7 +21,7 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS
if not config.UMAMI_API_TOKEN or not config.UMAMI_API_URL:
return 0 # Umami not configured — skip silently
started_at = datetime.utcnow()
started_at = utcnow()
try:
rows_synced = 0
@@ -34,7 +34,7 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS
# (Umami's metrics endpoint returns totals for the period,
# so we query one day at a time for daily granularity)
for day_offset in range(days_back):
day = datetime.utcnow() - timedelta(days=day_offset + 1)
day = utcnow() - timedelta(days=day_offset + 1)
metric_date = day.strftime("%Y-%m-%d")
start_ms = int(day.replace(hour=0, minute=0, second=0).timestamp() * 1000)
end_ms = int(day.replace(hour=23, minute=59, second=59).timestamp() * 1000)
@@ -96,21 +96,21 @@ async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS
(metric_date, page_count, visitors, br, avg_time),
)
duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
duration_ms = int((utcnow() - started_at).total_seconds() * 1000)
await execute(
"""INSERT INTO seo_sync_log
(source, status, rows_synced, started_at, completed_at, duration_ms)
VALUES ('umami', 'success', ?, ?, ?, ?)""",
(rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
(rows_synced, started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms),
)
return rows_synced
except Exception as exc:
duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
duration_ms = int((utcnow() - started_at).total_seconds() * 1000)
await execute(
"""INSERT INTO seo_sync_log
(source, status, rows_synced, error, started_at, completed_at, duration_ms)
VALUES ('umami', 'failed', 0, ?, ?, ?, ?)""",
(str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
(str(exc), started_at.strftime("%Y-%m-%d %H:%M:%S"), utcnow_iso(), duration_ms),
)
raise

View File

@@ -23,6 +23,7 @@ STATIC_PATHS = [
"/imprint",
"/suppliers",
"/markets",
"/market-score",
"/planner/",
"/directory/",
]

View File

@@ -549,4 +549,23 @@
.article-body a {
@apply text-electric underline hover:text-electric-hover;
}
.article-body details {
@apply border border-light-gray rounded-lg mb-3 overflow-hidden;
}
.article-body details summary {
@apply px-4 py-3 font-semibold text-navy cursor-pointer select-none;
list-style: none;
}
.article-body details summary::-webkit-details-marker { display: none; }
.article-body details summary::after {
content: '+';
@apply float-right text-slate font-normal;
}
.article-body details[open] summary::after {
content: '';
}
.article-body details > p,
.article-body details > div {
@apply px-4 pb-4 text-slate-dark;
}
}

View File

@@ -13,9 +13,9 @@ from ..core import (
config,
csrf_protect,
execute,
feature_gate,
fetch_all,
fetch_one,
feature_gate,
get_paddle_price,
is_flag_enabled,
)

View File

@@ -171,6 +171,7 @@
<li><a href="{{ url_for('planner.index') }}">{{ t.nav_planner }}</a></li>
<li><a href="{{ url_for('directory.index') }}">{{ t.nav_directory }}</a></li>
<li><a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a></li>
<li><a href="{{ url_for('public.market_score') }}">{{ t.footer_market_score }}</a></li>
<li><a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a></li>
</ul>
</div>

View File

@@ -5,12 +5,10 @@ NOT behind @role_required: Resend posts here unauthenticated.
Verification uses RESEND_WEBHOOK_SECRET via the Resend SDK.
"""
from datetime import datetime
import resend
from quart import Blueprint, jsonify, request
from .core import config, execute
from .core import config, execute, utcnow_iso
bp = Blueprint("webhooks", __name__, url_prefix="/webhooks")
@@ -67,7 +65,7 @@ async def _handle_delivery_event(event_type: str, data: dict) -> None:
return
last_event, ts_col = _EVENT_UPDATES[event_type]
now = datetime.utcnow().isoformat()
now = utcnow_iso()
if ts_col:
await execute(
@@ -87,7 +85,7 @@ async def _handle_inbound(data: dict) -> None:
if not resend_id:
return
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"""INSERT OR IGNORE INTO inbound_emails
(resend_id, message_id, in_reply_to, from_addr, to_addr,

View File

@@ -7,9 +7,25 @@ import json
import traceback
from datetime import datetime, timedelta
from .core import EMAIL_ADDRESSES, config, execute, fetch_all, fetch_one, init_db, send_email
import logging
from .core import (
EMAIL_ADDRESSES,
config,
execute,
fetch_all,
fetch_one,
init_db,
send_email,
setup_logging,
utcnow,
utcnow_iso,
)
from .i18n import get_translations
logger = logging.getLogger(__name__)
scheduler_logger = logging.getLogger(f"{__name__}.scheduler")
# Task handlers registry
HANDLERS: dict[str, callable] = {}
@@ -29,7 +45,7 @@ def _email_wrap(body: str, lang: str = "en", preheader: str = "") -> str:
preheader: hidden preview text shown in email client list views.
"""
year = datetime.utcnow().year
year = utcnow().year
tagline = _t("email_footer_tagline", lang)
copyright_text = _t("email_footer_copyright", lang, year=year, app_name=config.APP_NAME)
# Hidden preheader trick: visible text + invisible padding to prevent
@@ -132,15 +148,15 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None)
(
task_name,
json.dumps(payload or {}),
(run_at or datetime.utcnow()).isoformat(),
datetime.utcnow().isoformat(),
(run_at or utcnow()).strftime("%Y-%m-%d %H:%M:%S"),
utcnow_iso(),
),
)
async def get_pending_tasks(limit: int = 10) -> list[dict]:
"""Get pending tasks ready to run."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
return await fetch_all(
"""
SELECT * FROM tasks
@@ -156,7 +172,7 @@ async def mark_complete(task_id: int) -> None:
"""Mark task as completed."""
await execute(
"UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), task_id),
(utcnow_iso(), task_id),
)
@@ -167,7 +183,7 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
if retries < max_retries:
# Exponential backoff: 1min, 5min, 25min
delay = timedelta(minutes=5**retries)
run_at = datetime.utcnow() + delay
run_at = utcnow() + delay
await execute(
"""
@@ -208,10 +224,7 @@ async def handle_send_magic_link(payload: dict) -> None:
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
if config.DEBUG:
print(f"\n{'=' * 60}")
print(f" MAGIC LINK for {payload['email']}")
print(f" {link}")
print(f"{'=' * 60}\n")
logger.debug("MAGIC LINK for %s: %s", payload["email"], link)
expiry_minutes = config.MAGIC_LINK_EXPIRY_MINUTES
body = (
@@ -243,10 +256,7 @@ async def handle_send_quote_verification(payload: dict) -> None:
)
if config.DEBUG:
print(f"\n{'=' * 60}")
print(f" QUOTE VERIFICATION for {payload['email']}")
print(f" {link}")
print(f"{'=' * 60}\n")
logger.debug("QUOTE VERIFICATION for %s: %s", payload["email"], link)
first_name = (
payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there"
@@ -385,13 +395,13 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
@task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens."""
await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (datetime.utcnow().isoformat(),))
await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (utcnow_iso(),))
@task("cleanup_rate_limits")
async def handle_cleanup_rate_limits(payload: dict) -> None:
"""Clean up old rate limit entries."""
cutoff = (datetime.utcnow() - timedelta(hours=1)).isoformat()
cutoff = (utcnow() - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
await execute("DELETE FROM rate_limits WHERE timestamp < ?", (cutoff,))
@@ -485,7 +495,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
# Send to supplier contact email or general contact
to_email = supplier.get("contact_email") or supplier.get("contact") or ""
if not to_email:
print(f"[WORKER] No email for supplier {supplier_id}, skipping lead forward")
logger.warning("No email for supplier %s, skipping lead forward", supplier_id)
return
await send_email(
@@ -497,7 +507,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None:
)
# Update email_sent_at on lead_forward
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE lead_forwards SET email_sent_at = ? WHERE lead_id = ? AND supplier_id = ?",
(now, lead_id, supplier_id),
@@ -588,9 +598,9 @@ async def handle_refill_monthly_credits(payload: dict) -> None:
for s in suppliers:
try:
await monthly_credit_refill(s["id"])
print(f"[WORKER] Refilled credits for supplier {s['id']}")
logger.info("Refilled credits for supplier %s", s["id"])
except Exception as e:
print(f"[WORKER] Failed to refill credits for supplier {s['id']}: {e}")
logger.error("Failed to refill credits for supplier %s: %s", s["id"], e)
@task("generate_business_plan")
@@ -621,7 +631,7 @@ async def handle_generate_business_plan(payload: dict) -> None:
file_path.write_bytes(pdf_bytes)
# Update record
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await execute(
"UPDATE business_plan_exports SET status = 'ready', file_path = ?, completed_at = ? WHERE id = ?",
(str(file_path), now, export_id),
@@ -651,7 +661,7 @@ async def handle_generate_business_plan(payload: dict) -> None:
email_type="business_plan",
)
print(f"[WORKER] Generated business plan PDF: export_id={export_id}")
logger.info("Generated business plan PDF: export_id=%s", export_id)
except Exception:
await execute(
@@ -664,7 +674,7 @@ async def handle_generate_business_plan(payload: dict) -> None:
@task("cleanup_old_tasks")
async def handle_cleanup_tasks(payload: dict) -> None:
"""Clean up completed/failed tasks older than 7 days."""
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
cutoff = (utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
await execute(
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,)
)
@@ -680,7 +690,7 @@ async def handle_sync_gsc(payload: dict) -> None:
from .seo import sync_gsc
days_back = payload.get("days_back", 3)
rows = await sync_gsc(days_back=days_back)
print(f"[WORKER] GSC sync complete: {rows} rows")
logger.info("GSC sync complete: %s rows", rows)
@task("sync_bing")
@@ -689,7 +699,7 @@ async def handle_sync_bing(payload: dict) -> None:
from .seo import sync_bing
days_back = payload.get("days_back", 3)
rows = await sync_bing(days_back=days_back)
print(f"[WORKER] Bing sync complete: {rows} rows")
logger.info("Bing sync complete: %s rows", rows)
@task("sync_umami")
@@ -698,7 +708,7 @@ async def handle_sync_umami(payload: dict) -> None:
from .seo import sync_umami
days_back = payload.get("days_back", 3)
rows = await sync_umami(days_back=days_back)
print(f"[WORKER] Umami sync complete: {rows} rows")
logger.info("Umami sync complete: %s rows", rows)
@task("cleanup_seo_metrics")
@@ -706,7 +716,7 @@ async def handle_cleanup_seo_metrics(payload: dict) -> None:
"""Delete SEO metrics older than 12 months."""
from .seo import cleanup_old_metrics
deleted = await cleanup_old_metrics(retention_days=365)
print(f"[WORKER] Cleaned up {deleted} old SEO metric rows")
logger.info("Cleaned up %s old SEO metric rows", deleted)
@task("generate_articles")
@@ -722,7 +732,7 @@ async def handle_generate_articles(payload: dict) -> None:
limit = payload.get("limit", 500)
count = await generate_articles(slug, start_date, articles_per_day, limit=limit)
print(f"[WORKER] Generated {count} articles for template '{slug}'")
logger.info("Generated %s articles for template '%s'", count, slug)
# =============================================================================
@@ -745,20 +755,21 @@ async def process_task(task: dict) -> None:
payload = json.loads(task["payload"]) if task["payload"] else {}
await handler(payload)
await mark_complete(task_id)
print(f"[WORKER] Completed: {task_name} (id={task_id})")
logger.info("Completed: %s (id=%s)", task_name, task_id)
except Exception as e:
error = f"{e}\n{traceback.format_exc()}"
await mark_failed(task_id, error, retries)
print(f"[WORKER] Failed: {task_name} (id={task_id}): {e}")
logger.error("Failed: %s (id=%s): %s", task_name, task_id, e)
async def run_worker(poll_interval: float = 1.0) -> None:
"""Main worker loop."""
print("[WORKER] Starting...")
setup_logging()
logger.info("Starting...")
await init_db()
from .analytics import open_analytics_db
open_analytics_db()
print("[WORKER] Analytics DB opened.")
logger.info("Analytics DB opened.")
while True:
try:
@@ -771,13 +782,14 @@ async def run_worker(poll_interval: float = 1.0) -> None:
await asyncio.sleep(poll_interval)
except Exception as e:
print(f"[WORKER] Error: {e}")
logger.error("Error: %s", e)
await asyncio.sleep(poll_interval * 5)
async def run_scheduler() -> None:
"""Schedule periodic cleanup tasks."""
print("[SCHEDULER] Starting...")
setup_logging()
scheduler_logger.info("Starting...")
await init_db()
last_credit_refill = None
@@ -791,14 +803,12 @@ async def run_scheduler() -> None:
await enqueue("cleanup_old_tasks")
# Monthly credit refill — run on the 1st of each month
from datetime import datetime
today = datetime.utcnow()
today = utcnow()
this_month = f"{today.year}-{today.month:02d}"
if today.day == 1 and last_credit_refill != this_month:
await enqueue("refill_monthly_credits")
last_credit_refill = this_month
print(f"[SCHEDULER] Queued monthly credit refill for {this_month}")
scheduler_logger.info("Queued monthly credit refill for %s", this_month)
# Daily SEO metrics sync — run once per day after 6am UTC
# (GSC data has ~2 day delay, syncing at 6am ensures data is ready)
@@ -809,12 +819,12 @@ async def run_scheduler() -> None:
await enqueue("sync_umami")
await enqueue("cleanup_seo_metrics")
last_seo_sync_date = today_date
print(f"[SCHEDULER] Queued SEO metric syncs for {today_date}")
scheduler_logger.info("Queued SEO metric syncs for %s", today_date)
await asyncio.sleep(3600) # 1 hour
except Exception as e:
print(f"[SCHEDULER] Error: {e}")
scheduler_logger.error("Error: %s", e)
await asyncio.sleep(60)

View File

@@ -8,7 +8,7 @@ sitemap integration, admin CRUD routes, and path collision prevention.
import importlib
import json
import sqlite3
from datetime import date, datetime
from datetime import date
from pathlib import Path
import pytest
@@ -19,7 +19,7 @@ from padelnomics.content.routes import (
bake_scenario_cards,
is_reserved_path,
)
from padelnomics.core import execute, fetch_all, fetch_one, slugify
from padelnomics.core import execute, fetch_all, fetch_one, slugify, utcnow_iso
from padelnomics.planner.calculator import calc, validate_state
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "padelnomics" / "migrations" / "schema.sql"
@@ -70,7 +70,7 @@ async def _create_published_scenario(slug="test-scenario", city="TestCity", coun
async def _create_article(slug="test-article", url_path="/test-article",
status="published", published_at=None):
"""Insert an article row, return its id."""
pub = published_at or datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
pub = published_at or utcnow_iso()
return await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
@@ -936,8 +936,7 @@ class TestRouteRegistration:
@pytest.fixture
async def admin_client(app, db):
"""Test client with admin user (has admin role)."""
from datetime import datetime
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),

View File

@@ -3,9 +3,8 @@ Tests for the credit system (credits.py).
Pure SQL operations against real in-memory SQLite — no mocking needed.
"""
from datetime import datetime
import pytest
from padelnomics.core import utcnow_iso
from padelnomics.credits import (
InsufficientCredits,
add_credits,
@@ -24,7 +23,7 @@ from padelnomics.credits import (
@pytest.fixture
async def supplier(db):
"""Supplier with credit_balance=100, monthly_credits=30, tier=growth."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
@@ -41,7 +40,7 @@ async def supplier(db):
@pytest.fixture
async def lead(db):
"""Lead request with heat_score=warm, credit_cost=20."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"""INSERT INTO lead_requests
(lead_type, heat_score, credit_cost, status, created_at)
@@ -154,7 +153,7 @@ class TestAlreadyUnlocked:
assert await already_unlocked(supplier["id"], lead["id"]) is False
async def test_returns_true_after_unlock(self, db, supplier, lead):
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await db.execute(
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
VALUES (?, ?, 20, ?)""",
@@ -210,7 +209,7 @@ class TestUnlockLead:
async def test_raises_insufficient_credits(self, db, lead):
"""Supplier with only 5 credits tries to unlock a 20-credit lead."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
@@ -247,7 +246,7 @@ class TestMonthlyRefill:
async def test_noop_when_no_monthly_credits(self, db):
"""Supplier with monthly_credits=0 gets no refill."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,

View File

@@ -7,15 +7,13 @@ Integration tests exercise full request/response flows via Quart test client.
"""
import sqlite3
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.core import utcnow_iso
from padelnomics.migrations.migrate import migrate
from padelnomics import core
# ── Fixtures & helpers ────────────────────────────────────────────
@@ -30,7 +28,7 @@ def mock_csrf_validation():
@pytest.fixture
async def admin_client(app, db):
"""Test client with an admin-role user session (module-level, follows test_content.py)."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("flags_admin@test.com", "Flags Admin", now),
@@ -293,8 +291,9 @@ class TestLeadUnlockGate:
@pytest.mark.asyncio
async def test_route_imports_is_flag_enabled(self):
"""suppliers/routes.py imports is_flag_enabled (gate is wired up)."""
from padelnomics.suppliers.routes import unlock_lead
import inspect
from padelnomics.suppliers.routes import unlock_lead
src = inspect.getsource(unlock_lead)
assert "is_flag_enabled" in src
assert "lead_unlock" in src

View File

@@ -57,6 +57,8 @@ _IDENTICAL_VALUE_ALLOWLIST = {
# Business plan — Indoor/Outdoor same in DE, financial abbreviations
"bp_indoor", "bp_outdoor",
"bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex",
# Market Score — branded term kept in English in DE
"footer_market_score",
}

View File

@@ -0,0 +1,59 @@
"""Tests for the Market Score methodology page."""
async def test_en_returns_200(client):
resp = await client.get("/en/market-score")
assert resp.status_code == 200
text = await resp.get_data(as_text=True)
assert "Market Score" in text
assert "padelnomics" in text
async def test_de_returns_200(client):
resp = await client.get("/de/market-score")
assert resp.status_code == 200
text = await resp.get_data(as_text=True)
assert "Market Score" in text
assert "padelnomics" in text
async def test_legacy_redirect(client):
resp = await client.get("/market-score")
assert resp.status_code == 301
assert resp.headers["Location"].endswith("/en/market-score")
async def test_contains_jsonld(client):
resp = await client.get("/en/market-score")
text = await resp.get_data(as_text=True)
assert '"@type": "WebPage"' in text
assert '"@type": "FAQPage"' in text
assert '"@type": "BreadcrumbList"' in text
async def test_contains_faq_section(client):
resp = await client.get("/en/market-score")
text = await resp.get_data(as_text=True)
assert "Frequently Asked Questions" in text
assert "<details" in text
async def test_de_contains_faq_section(client):
resp = await client.get("/de/market-score")
text = await resp.get_data(as_text=True)
assert "Häufig gestellte Fragen" in text
async def test_contains_og_tags(client):
resp = await client.get("/en/market-score")
text = await resp.get_data(as_text=True)
assert 'og:title' in text
assert 'og:description' in text
async def test_footer_has_market_score_link(client):
resp = await client.get("/en/market-score")
text = await resp.get_data(as_text=True)
assert "/en/market-score" in text
# Footer should link to market score page
assert "Market Score" in text

View File

@@ -1,9 +1,10 @@
"""Tests for the SEO metrics module: queries, sync functions, admin routes."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from padelnomics.core import utcnow_iso
from padelnomics.seo._queries import (
cleanup_old_metrics,
get_article_scorecard,
@@ -21,11 +22,11 @@ from padelnomics import core
# ── Fixtures ──────────────────────────────────────────────────
def _today():
return datetime.utcnow().strftime("%Y-%m-%d")
return datetime.now(UTC).strftime("%Y-%m-%d")
def _days_ago(n: int) -> str:
return (datetime.utcnow() - timedelta(days=n)).strftime("%Y-%m-%d")
return (datetime.now(UTC) - timedelta(days=n)).strftime("%Y-%m-%d")
@pytest.fixture
@@ -72,7 +73,7 @@ async def seo_data(db):
@pytest.fixture
async def articles_data(db, seo_data):
"""Create articles that match the SEO data URLs."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
pub = _days_ago(10)
for title, url, tpl, lang in [
@@ -91,7 +92,7 @@ async def articles_data(db, seo_data):
@pytest.fixture
async def admin_client(app, db):
"""Authenticated admin client."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("admin@test.com", "Admin", now),
@@ -258,7 +259,7 @@ class TestSyncStatus:
"""Tests for get_sync_status()."""
async def test_returns_last_sync_per_source(self, db):
now = datetime.utcnow().isoformat()
now = utcnow_iso()
await db.execute(
"""INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms)
VALUES ('gsc', 'success', 100, ?, ?, 500)""",
@@ -286,7 +287,7 @@ class TestCleanupOldMetrics:
"""Tests for cleanup_old_metrics()."""
async def test_deletes_old_data(self, db):
old_date = (datetime.utcnow() - timedelta(days=400)).strftime("%Y-%m-%d")
old_date = (datetime.now(UTC) - timedelta(days=400)).strftime("%Y-%m-%d")
recent_date = _today()
await db.execute(

View File

@@ -8,19 +8,16 @@ supervisor.py lives in src/padelnomics/ (not a uv workspace package), so we
add src/ to sys.path before importing.
"""
import sys
# Load supervisor.py directly by path — avoids clashing with the web app's
# 'padelnomics' namespace (which is the installed web package).
import importlib.util as _ilu
import textwrap
import tomllib
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Load supervisor.py directly by path — avoids clashing with the web app's
# 'padelnomics' namespace (which is the installed web package).
import importlib.util as _ilu
_SUP_PATH = Path(__file__).parent.parent.parent / "src" / "padelnomics" / "supervisor.py"
_spec = _ilu.spec_from_file_location("padelnomics_supervisor", _SUP_PATH)
sup = _ilu.module_from_spec(_spec)
@@ -32,7 +29,6 @@ from padelnomics_extract.proxy import (
make_sticky_selector,
)
# ── load_workflows ────────────────────────────────────────────────

View File

@@ -5,11 +5,12 @@ POST real webhook payloads to /billing/webhook/paddle and verify DB state.
Uses the existing client, db, sign_payload from conftest.
"""
import json
from datetime import datetime
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
import pytest
from conftest import sign_payload
from padelnomics.core import utcnow_iso
WEBHOOK_PATH = "/billing/webhook/paddle"
SIG_HEADER = "Paddle-Signature"
@@ -21,7 +22,7 @@ SIG_HEADER = "Paddle-Signature"
@pytest.fixture
async def supplier(db):
"""Supplier with tier=free, credit_balance=0."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
@@ -38,7 +39,7 @@ async def supplier(db):
@pytest.fixture
async def paddle_products(db):
"""Insert paddle_products rows for all keys the handlers need."""
now = datetime.utcnow().isoformat()
now = utcnow_iso()
products = [
("credits_25", "pri_credits25", "Credit Pack 25", 999, "one_time"),
("credits_100", "pri_credits100", "Credit Pack 100", 3290, "one_time"),
@@ -175,7 +176,7 @@ class TestStickyBoostPurchase:
assert boosts[0][1] == "active"
# expires_at should be ~7 days from now
expires = datetime.fromisoformat(boosts[0][2])
assert abs((expires - datetime.utcnow()).days - 7) <= 1
assert abs((expires - datetime.now(UTC).replace(tzinfo=None)).days - 7) <= 1
# Verify sticky_until set on supplier
sup = await db.execute_fetchall(
@@ -202,7 +203,7 @@ class TestStickyBoostPurchase:
assert len(boosts) == 1
assert boosts[0][0] == "sticky_month"
expires = datetime.fromisoformat(boosts[0][1])
assert abs((expires - datetime.utcnow()).days - 30) <= 1
assert abs((expires - datetime.now(UTC).replace(tzinfo=None)).days - 30) <= 1
async def test_sticky_boost_sets_country(self, client, db, supplier, paddle_products):
payload = make_transaction_payload(
@@ -387,7 +388,7 @@ class TestBusinessPlanPurchase:
self, client, db, supplier, paddle_products, test_user,
):
# Need a scenario for the export
now = datetime.utcnow().isoformat()
now = utcnow_iso()
async with db.execute(
"""INSERT INTO scenarios (user_id, name, state_json, created_at)
VALUES (?, 'Test Scenario', '{}', ?)""",