Compare commits
192 Commits
v5
...
v202603051
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add5f8ddfa | ||
|
|
15ca316682 | ||
|
|
103ef73cf5 | ||
|
|
aa27f14f3c | ||
|
|
8205744444 | ||
|
|
1cbefe349c | ||
|
|
003f19e071 | ||
|
|
c3f15535b8 | ||
|
|
fcb8ec4227 | ||
|
|
6b7fa45bce | ||
|
|
0d8687859d | ||
|
|
b064e18aa1 | ||
|
|
dc68976148 | ||
|
|
60fa2bc720 | ||
|
|
66c2dfce66 | ||
|
|
6e3c5554aa | ||
|
|
ad02140594 | ||
|
|
5bcd87d7e5 | ||
|
|
77772b7ea4 | ||
|
|
59f1f0d699 | ||
|
|
0a89ba2213 | ||
|
|
6e936dbb95 | ||
|
|
edf678ac4e | ||
|
|
0eef455543 | ||
|
|
8e53fda283 | ||
|
|
db0d7cfee9 | ||
|
|
61c197d233 | ||
|
|
2e68cfbe4f | ||
|
|
7af6f32a2b | ||
|
|
53fdbd9fd5 | ||
|
|
81487d6f01 | ||
|
|
477f635bc5 | ||
|
|
4dbded74ca | ||
|
|
230406f34f | ||
|
|
7da6a4737d | ||
|
|
710e21a186 | ||
|
|
72c4de91b0 | ||
|
|
046be665db | ||
|
|
7c5fa86fb8 | ||
|
|
0a9f980813 | ||
|
|
2682e810fa | ||
|
|
10af6a284c | ||
|
|
68f354ac2b | ||
|
|
0b74156ef7 | ||
|
|
fab16cb48f | ||
|
|
062a6d2766 | ||
|
|
80c2f111d2 | ||
|
|
7ae8334d7a | ||
|
|
032fe8d86c | ||
|
|
4907bc8b64 | ||
|
|
bf69270913 | ||
|
|
8f0a56079f | ||
|
|
7af9b2c82c | ||
|
|
276328af33 | ||
|
|
a00c8727d7 | ||
|
|
0fc0ca66b1 | ||
|
|
385deb7f81 | ||
|
|
3ddb26ae0f | ||
|
|
695e956501 | ||
|
|
a862d21269 | ||
|
|
f4f8a45654 | ||
|
|
9e471f8960 | ||
|
|
48401bd2af | ||
|
|
cd02726d4c | ||
|
|
fbc259cafa | ||
|
|
992e448c18 | ||
|
|
777a4af505 | ||
|
|
2c8c662e9e | ||
|
|
34f8e45204 | ||
|
|
6b9187f420 | ||
|
|
94d92328b8 | ||
|
|
100e200c3b | ||
|
|
70628ea881 | ||
|
|
d619f5e1ef | ||
|
|
2a7eed1576 | ||
|
|
162e633c62 | ||
|
|
31017457a6 | ||
|
|
f93e4fd0d1 | ||
|
|
567798ebe1 | ||
|
|
b32b7cd748 | ||
|
|
6774254cb0 | ||
|
|
e87a7fc9d6 | ||
|
|
3d7a72ba26 | ||
|
|
a55501f2ea | ||
|
|
d3626193c5 | ||
|
|
7ea1f234e8 | ||
|
|
c1cf472caf | ||
|
|
f9e22a72dd | ||
|
|
ce466e3f7f | ||
|
|
563bd1fb2e | ||
|
|
b980b8f567 | ||
|
|
0733f1c2a1 | ||
|
|
320777d24c | ||
|
|
92930ac717 | ||
|
|
0cfc841c08 | ||
|
|
36deaba00e | ||
|
|
9608b7f601 | ||
|
|
0811b30cbd | ||
|
|
7d2950928e | ||
|
|
65e51d2972 | ||
|
|
c5d872ec55 | ||
|
|
75305935bd | ||
|
|
99cb0ac005 | ||
|
|
a15c32d398 | ||
|
|
97c5846d51 | ||
|
|
0d903ec926 | ||
|
|
42c49e383c | ||
|
|
1c0edff3e5 | ||
|
|
8a28b94ec2 | ||
|
|
9b54f2d544 | ||
|
|
08bd2b2989 | ||
|
|
81a57db272 | ||
|
|
bce6b2d340 | ||
|
|
f92d863781 | ||
|
|
a3dd37b1be | ||
|
|
e5cbcf462e | ||
|
|
169092c8ea | ||
|
|
6ae16f6c1f | ||
|
|
8b33daa4f3 | ||
|
|
a898a06575 | ||
|
|
219554b7cb | ||
|
|
1aedf78ec6 | ||
|
|
8f2ffd432b | ||
|
|
c9dec066f7 | ||
|
|
fea4f85da3 | ||
|
|
2590020014 | ||
|
|
a72f7721bb | ||
|
|
849dc8359c | ||
|
|
ec839478c3 | ||
|
|
47acf4d3df | ||
|
|
53117094ee | ||
|
|
6076a0b30f | ||
|
|
8dbbd0df05 | ||
|
|
b1eeb0a0ac | ||
|
|
6aae92fc58 | ||
|
|
86be044116 | ||
|
|
5de0676f44 | ||
|
|
81ec8733c7 | ||
|
|
8a921ee18a | ||
|
|
07d8ea1c0e | ||
|
|
370fc1f70b | ||
|
|
e0c3f38c0a | ||
|
|
f9faa02683 | ||
|
|
109da23902 | ||
|
|
34065fa2ac | ||
|
|
d1a10ff243 | ||
|
|
5f48449d25 | ||
|
|
b7e44ac5b3 | ||
|
|
c2dfefcc1e | ||
|
|
e9d1b74618 | ||
|
|
4b5c237bee | ||
|
|
8c4a4078f9 | ||
|
|
5f756a2ba5 | ||
|
|
4ac17af503 | ||
|
|
0984657e72 | ||
|
|
73547ec876 | ||
|
|
129ca26143 | ||
|
|
9ea4ff55fa | ||
|
|
8a91fc752b | ||
|
|
4783067c6e | ||
|
|
c1e1f42aad | ||
|
|
ecd1cdd27a | ||
|
|
24ec7060b3 | ||
|
|
5c22ea9780 | ||
|
|
aee3733b49 | ||
|
|
51d9aab4a0 | ||
|
|
1fdd2d07a4 | ||
|
|
2214d7a58f | ||
|
|
0f360fd230 | ||
|
|
85b6aa0d0a | ||
|
|
bc7e40b531 | ||
|
|
ef85d3bb36 | ||
|
|
4d45b99cd8 | ||
|
|
e62aad148b | ||
|
|
b5db9d16b9 | ||
|
|
2e149fc1db | ||
|
|
6fb1e990e3 | ||
|
|
6edf8ba65e | ||
|
|
ed0a578050 | ||
|
|
c1cdeec6be | ||
|
|
710624f417 | ||
|
|
6cf98f44d4 | ||
|
|
60659a5ec5 | ||
|
|
beb4195f16 | ||
|
|
88cc857f3a | ||
|
|
9116625884 | ||
|
|
1af65bb46f | ||
|
|
9b0bfc478d | ||
|
|
adf22924f6 | ||
|
|
09665b7786 | ||
|
|
93349923bd | ||
|
|
642041b32b |
@@ -3,6 +3,8 @@ APP_NAME=ENC[AES256_GCM,data:Vic/MJYoxZo8JAI=,iv:n1SEGQaGeZtYMtLmDRFiljDBbNKFvCz
|
||||
SECRET_KEY=ENC[AES256_GCM,data:a3Bhj3gSQaE3llRWBYzpjoFDhhhSsNee67jXJs7+qn4=,iv:yvrx78X5Ut4DBSlmBnIn09ESVc/tuDiwiV4njmjcvko=,tag:cbFUTAEpX+isQD9FCVllsw==,type:str]
|
||||
BASE_URL=ENC[AES256_GCM,data:LcbPDZf9Pwcuv7RxN9xhNfa9Tufi,iv:cOdjW9nNe+BuDXh+dL4b5LFQL2mKBiKV0FaEsDGMAQc=,tag:3uAn3AIwsztIfGpkQLD5Fg==,type:str]
|
||||
DEBUG=ENC[AES256_GCM,data:qrEGkA==,iv:bCyEDWiEzolHo4vabiyYTsqM0eUaBmNbXYYu4wCsaeE=,tag:80gnDNbdZHRWVEYtuA1M2Q==,type:str]
|
||||
#ENC[AES256_GCM,data:YB5h,iv:2HFpvHNebAB9M/44rtPk/QpFV9hNKOlV/099OSjPnOA=,tag:BVj8vGy6K3LW/wb1vcZ+Ug==,type:comment]
|
||||
GITEA_TOKEN=ENC[AES256_GCM,data:aIM7vQXxFbz7FDdXEdwtelvmXAdLgJfWNCSPeK//NlveQrU5cLDt8w==,iv:9qhjk52ZAs+y5WwP5WebMUwHhu6JNdHzAsEOpznrwBw=,tag:WnCDA4hAccMFs6vXVVKqxw==,type:str]
|
||||
#ENC[AES256_GCM,data:YmlGAWpXxRCqam3oTWtGxHDXC+svEXI4HyUxrm/8OcKTuJsYPcL1WcnYqrP5Mf5lU5qPezEXUrrgZy8vjVW6qAbb0IA2PMM4Kg==,iv:dx6Dn99dJgjwyvUp8NAygXjRQ50yKYFeC73Oqt9WvmY=,tag:6JLF2ixSAv39VkKt6+cecQ==,type:comment]
|
||||
ADMIN_EMAILS=ENC[AES256_GCM,data:hlG8b32WlD4ems3VKQ==,iv:wWO08dmX4oLhHulXg4HUG0PjRnFiX19RUTkTvjqIw5I=,tag:KMjXsBt7aE/KqlCfV+fdMg==,type:str]
|
||||
#ENC[AES256_GCM,data:b2wQxnL8Q2Bp,iv:q8ep3yUPzCumpZpljoVL2jbcPdsI5c2piiZ0x5k10Mw=,tag:IbjkT0Mjgu9n+6FGiPVihg==,type:comment]
|
||||
@@ -29,12 +31,18 @@ RESEND_WEBHOOK_SECRET=
|
||||
#ENC[AES256_GCM,data:1HqXvAspvNIUNpCxJwge3mEsyO0Y/EWvD3vbLxkgGqIex0hABcupX/Nzk15u8iOY5JWvvEuAO414MNt6mFvnWBDpEw==,iv:N7gCzTNJAR/ljx5gGsX+ieZctya8vQbCIb3hw49OhXg=,tag:PJKNyzhrit5VgIXl+cNlbQ==,type:comment]
|
||||
#ENC[AES256_GCM,data:do6DZ/1Osc5y4xseG8Q8bDX84JBHLzvmVbHiqxP7ChlicmzYBkZ85g43BuM7V0KInFTFgvaC8xmFic+2d37Holuf1ywdAjbLkRhg,iv:qrNmhPbmFDr2ynIF5EdOLZl3FI5f68WDrxuHMkAzuuU=,tag:761gYOlEdNM+e1//1MbCHg==,type:comment]
|
||||
#ENC[AES256_GCM,data:dseLIQiUEU20xJqoq2dkFho9SnKyoyQ8pStjvfxwnj8v18/ua0TH/PDx/qwIp9z5kEIvbsz5ycJesFfKPhLA5juGcdCbi5zBmZRWYg==,iv:7JUmRnohJt0H5yoJXVD3IauuJkpPHDPyY02OWHWb9Nw=,tag:KcM6JGT01Aa1kTx+U30UKQ==,type:comment]
|
||||
#ENC[AES256_GCM,data:VXv1O5oRNTws8wbx/nZWH6Q=,iv:M/XwF6Zef+xlJ/8AAVI1zSmsEUNYL+0twzxXwkf8moY=,tag:y3Nu5akuiKtEIMeZhSNIkw==,type:comment]
|
||||
PAYMENT_PROVIDER=ENC[AES256_GCM,data:7uxz3xmr,iv:4uEOA7ZjehD1bF91Gxl0+OxnvlZW3QIq22MhnYM43uE=,tag:XvHqyRM+ugnWTUN9GFJ3fQ==,type:str]
|
||||
#ENC[AES256_GCM,data:GgXo4zkhJsxXEk8F5a/+wdbvBUGN00MUAutZYLDEqqN4T1rZu92fioOLx7MEoC0b8i61,iv:f1hUBoZpmnzXNcikf/anVNdRSHNwVmmjdIcba3eiRI4=,tag:uWpF40uuiXyWqKrYGyLVng==,type:comment]
|
||||
PADDLE_API_KEY=
|
||||
PADDLE_CLIENT_TOKEN=
|
||||
PADDLE_WEBHOOK_SECRET=
|
||||
PADDLE_NOTIFICATION_SETTING_ID=
|
||||
PADDLE_ENVIRONMENT=ENC[AES256_GCM,data:KIGNxEaodA==,iv:SRebaYRpVJR0LpfalBZJLTE8qBGwWZB/Fx3IokQF99Q=,tag:lcC56e4FjVkCiyaq41vxcQ==,type:str]
|
||||
#ENC[AES256_GCM,data:sk79dbsswA==,iv:J8CyJt/WOMLd7CZNutDwIOtAOAooaMsLPO35gfWo+Nc=,tag:JQcGMYdgcQgtIWKcqXZkNQ==,type:comment]
|
||||
STRIPE_API_PUBLIC_KEY=ENC[AES256_GCM,data:WhWvIzNd1sS+IrrEdE+FJI6ZgEiNlgG3oxC8VoDzXf0z1oH1wgY6m9wUq6UEZZyzeiRGAeAylOk6wHJ+Lx4+zx2cfv+yweX7I3Sq5VN2D1OBPiQ3Kde4zm5cXqA92jRkLAomZxw/DkeiB14=,iv:Rb3GSLMVSySR++X240MICsXbVtOuqZNjm+nIe+s65dU=,tag:z82dyRzmxF3e87Sm2F+4Qw==,type:str]
|
||||
STRIPE_API_PRIVATE_KEY=ENC[AES256_GCM,data:/62y1Iv2Op21eEvT3BosgWD0S3YqGMgdfb2Edjhq2cuh32B3eH5fh9FaqBc3CvJpM7R79hy9jTnV3CTjlCkvrXGCLDnFY2a6kvSz5f+v2d/lsr8zvFLs6OP+bhssHdVygfIwz9ye46tfcFk=,iv:iw0NAYUf/gCM4awb2tKBEKuo/j7kkpVP6JjIIdVy7O8=,tag:GO3ASp5bykwHDHNkCYsdiA==,type:str]
|
||||
STRIPE_ACCOUNT_ID=ENC[AES256_GCM,data:ahJsOgZLRi5n9P7Dy0U1rvmhwr/B,iv:aoVA3M8Faqv1kZwTtagD0WLVipkA5nkX5uSjtHl14+I=,tag:XwLOu9ZiHUizcsnk73bt1w==,type:str]
|
||||
#ENC[AES256_GCM,data:2Hs7ds2ppeRqKB7EiAAbWqlainKdZ+eTYZSvPloirT4Hlsuf+zTwtJTA6RzHNCuK4em//jhOx8R2k80I,iv:1N6CNPqYWp3z8lm5e2Vp6OlpgHdMOiD7dsEYp23nMtA=,tag:ulWP/BFFoLljLMVCrsgizw==,type:comment]
|
||||
UMAMI_API_URL=ENC[AES256_GCM,data:oX/m95YB+S2ziUKoxDhsDzMhGZfxppw+w603tQ==,iv:GAj7ccF6seiCfLAh2XIjUi13RpgNA3GONMtINcG+KMw=,tag:mUfRlvaEWrw2QWFydtnbNA==,type:str]
|
||||
UMAMI_API_TOKEN=
|
||||
@@ -56,9 +64,10 @@ WORKFLOWS_PATH=ENC[AES256_GCM,data:PehxEUMb1K3F1557BY3IqKD7sbJcoaIjnQvboBRJ1g==,
|
||||
ALERT_WEBHOOK_URL=
|
||||
NTFY_TOKEN=
|
||||
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
|
||||
PROXY_URLS=ENC[AES256_GCM,data:CzRaK0piUQfvuYYsdz0i2MEQIphKi0BhNvHw9alo46aTH+kqEKvoS7dKEKzyU9VJ4TyNweInlVMxB962DsvRoBtnHwo/pUmYtVeEr2881clNgEiZVYRDFRdEbpULcLPDJa3ey1leqAAHlmiL0RQ6Qa57gPCOVBzVG6npGLKO+K8XVIb+BZMs9kEUOlw7iuqTJW5xPN/t4X/jHidEqfTSAl9b4vU4bsYVuY3yQrL+/V5QpTbyXlf+cMq3flpA3zE2Fxhalzg+c/wHMTrCksFwrCkrInW0kY9yPkA7usUWr1xwwaV3wIDoNQsLXpMd/3RztipNvKtOMRhRJOmjzP7BKhCJvvvKTV5p+mBCulFijbMQgArg3BqcFanfw3YZ4wPd4hp8q/vOhE/U9Wu0yrMmyWYFHYGQnFVARlBH7pwn/ez8W4KqRFveEAuev9CE7K7s5RqzPLelSkoa9UuiiULJ+t0LFgKlgxuLtQ8GdFdgsmBCxY/4U/xzvNdC82hD549z5nMWWlaUJm4onPWirT/RYm7j3v6z4mmNImI2W6rCNbvEvsXwWsciquVaBIgReA47p6/GTzZ9VZMyGr4PdzB87BJGAgX1W57WNdPAsRIF49XP2BU72RtRFxsUG8Ha2dc=,iv:a10Vpk7Zv8QqORuEcMlpcvtHO/zjBLaFphWPYBXwysc=,tag:8N66/R+CLqEZ45wj+tCt6w==,type:str]
|
||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
|
||||
PROXY_URLS_FALLBACK=ENC[AES256_GCM,data:95rwI7kKUj1YxLpjChtrM4f2EFUDzQdAg1e1MOHnLwQ9ZY54UNH7v4JcqTsvDk9D+0N/BIdwFSDi7pnCSd6BWFV+cQ==,iv:rm9HdBsibSne7JR6vWl+ao/GHb1rbuVdZZDUWhVbTnE=,tag:NJ2STxmFZPvFayfTrEEYbg==,type:str]
|
||||
PROXY_URLS_RESIDENTIAL=ENC[AES256_GCM,data:lfmlsjXFtL+zo40SNFLiFKaZiYvE7CNH+zRwjMK5pqPfCs0TlMX+Y9e1KmzAS+y/cI69TP5sgMPRBzER0Jn7RvH0KA==,iv:jBN/4/K5L5886G4rSzxt8V8u/57tAuj3R76haltzqeU=,tag:Xe6o9eg2PodfktDqmLgVNA==,type:str]
|
||||
PROXY_URLS_DATACENTER=ENC[AES256_GCM,data:Eec0X65EMsV2PD3Qvn+JjGqYaHtLupn0k99H918vmuRuAinP3rv/pwEoyKHmygazrUExg7U2PUELycyzq3lU6RIGtO+r0pRAn/n0S8RwdoZS,iv:T+bfbvULwSLRVD/hyW7rDN8tLLBf1FQkwCEbpiuBB+0=,tag:W/YHfl5U2yaA7ZOXgAFw+Q==,type:str]
|
||||
WEBSHARE_DOWNLOAD_URL=ENC[AES256_GCM,data:1D9VRZ3MCXPQWfiMH8+CLcrxeYnVVcQgZDvt5kltvbSTuSHQ2hHDmZpBkTOMIBJnw4JLZ2JQKHgG4OaYDtsM2VltFPnfwaRgVI9G5PSenR3o4PeQmYO1AqWOmjn19jPxNXRhEXdupP9UT+xQNXoBJsl6RR20XOpMA5AipUHmSjD0UIKXoZLU,iv:uWUkAydac//qrOTPUThuOLKAKXK4xcZmK9qBVFwpqt4=,tag:1vYhukBW9kEuSXCLAiZZmQ==,type:str]
|
||||
CIRCUIT_BREAKER_THRESHOLD=
|
||||
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
|
||||
GSC_SERVICE_ACCOUNT_PATH=
|
||||
@@ -70,7 +79,7 @@ GEONAMES_USERNAME=ENC[AES256_GCM,data:aSkVdLNrhiF6tlg=,iv:eemFGwDIv3EG/P3lVHGZj9
|
||||
CENSUS_API_KEY=ENC[AES256_GCM,data:qqG971573aGq9MiHI2xLlanKKFwjfcNNoMXtm8LNbyh0rMbQN2XukQ==,iv:az2i0ldH75nHGah4DeOxaXmDbVYqmC1c77ptZqFA9BI=,tag:zoDdKj9bR7fgIDo1/dEU2g==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxNWNmUzVNUGdWRnE0ZFpF\nM0JQZWZ3UDdEVzlwTmIxakxOZXBkT2x2ZlNrClRtV2M3S2daSGxUZmFDSWQ2Nmh4\neU51QndFcUxlSE00RFovOVJTcDZmUUUKLS0tIDcvL3hRMDRoMWZZSXljNzA3WG5o\nMWFic21MV0krMzlIaldBTVU0ZDdlTE0K7euGQtA+9lHNws+x7TMCArZamm9att96\nL8cXoUDWe5fNI5+M1bXReqVfNwPTwZsV6j/+ZtYKybklIzWz02Ex4A==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||
sops_lastmodified=2026-02-26T14:31:14Z
|
||||
sops_mac=ENC[AES256_GCM,data:iqFuTexTS9U/Nv8xoTpHljTNQTGX9ITcJ3AjhDEtxrh0Z9/lngfBvGtjiKmpwFGlobQw/x+/YLM+u3MhciwXF7qNwFfJ/StN2Y66uF71SxWotbL70Dxl4oWSVL3sU+2NYbw5yP0p+xCbE+rEd5SqAe6K5yyq5X25hz8fIapxlYA=,iv:foqoWQVMipuOAQ0Kp799PaIhCIrxV8T5cC811wIzxR8=,tag:yNfxSV3R21XEXksjmdsKBw==,type:str]
|
||||
sops_lastmodified=2026-03-03T15:16:35Z
|
||||
sops_mac=ENC[AES256_GCM,data:T0qph3KPd68Lo4hxd6ECP+wv87uwRFsAFZwnVyf/MXvuG7raraUW02RLox0xklVcKBJXk+9jM7ycQ1nuk95UIuu7uRU88g11RaAm67XaOsafgwDMrC17AjIlg0Vf0w64WAJBrQLaXhJlh/Gz45bXlz82F+XVnTW8fGCpHRZooMY=,iv:cDgMZX6FRVe9JqQXLN6OhO06Ysfg2AKP2hG0B/GeajU=,tag:vHavf9Hw2xqJrqM3vVUTjA==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
@@ -3,6 +3,7 @@ APP_NAME=ENC[AES256_GCM,data:ldJf4P0iD9ziMVg=,iv:hiVl2whhd02yZCafzBfbxX5/EU/suvz
|
||||
SECRET_KEY=ENC[AES256_GCM,data:hmlXm7NKVVFmeea4DnlrH/oSnsoaMAkUz42oWwFXOXL1XwAh3iemIKHUQOV2G4SPlmjfmEVQD64xbxaJW0OcPQ/8KqhrRYDsy0F/u0h7nmNQdwJrcvzcmbvjgcwU5IITPIr23d/W5PeSJzxhB93uaJ0+zFN2CyHfeewrJKafPfw=,iv:e+ZSLUO+dlt+ET8r/0/pf74UtGIBMkaVoJMWlJn1W5U=,tag:LdDCCrHcJnKLkKL/cY/R/Q==,type:str]
|
||||
BASE_URL=ENC[AES256_GCM,data:50k/RqlZ1EHqGM4UkSmTaCsuJgyU4w==,iv:f8zKr2jkts4RsawA97hzICHwj9Quzgp+Dw8AhQ7GSWA=,tag:9KhNvwmoOtDyuIql7okeew==,type:str]
|
||||
DEBUG=ENC[AES256_GCM,data:O0/uRF4=,iv:cZ+vyUuXjQOYYRf4l8lWS3JIWqL/w3pnlCTDPAZpB1E=,tag:OmJE9oJpzYzth0xwaMqADQ==,type:str]
|
||||
LANDING_DIR=ENC[AES256_GCM,data:rn8u+tGob0vU7kSAtxmrpYQlneesvyO10A==,iv:PuGtdcQBdRbnybulzd6L7JVQClcK3/QjMeYFXZSxGW0=,tag:K2PJPMCWXdqTlQpwP9+DOQ==,type:str]
|
||||
#ENC[AES256_GCM,data:xmJc6WTb3yumHzvLeA==,iv:9jKuYaDgm4zR/DTswIMwsajV0s5UTe+AOX4Sue0GPCs=,tag:b/7H9js1HmFYjuQE4zJz8w==,type:comment]
|
||||
ADMIN_EMAILS=ENC[AES256_GCM,data:R/2YTk8KDEpNQ71RN8Fm6miLZvXNJQ==,iv:kzmiaBK7KvnSjR5gx6lp7zEMzs5xRul6LBhmLf48bCU=,tag:csVZ0W1TxBAoJacQurW9VQ==,type:str]
|
||||
#ENC[AES256_GCM,data:S7Pdg9tcom3N,iv:OjmYk3pqbZHKPS1Y06w1y8BE7CU0y6Vx2wnio9tEhus=,tag:YAOGbrHQ+UOcdSQFWdiCDA==,type:comment]
|
||||
@@ -32,10 +33,6 @@ LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:pAqSkoJzsw==,iv:5J1Js7JPH/j1oTmEBdNXjwd
|
||||
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
||||
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
||||
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
||||
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
||||
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
|
||||
LANDING_DIR=ENC[AES256_GCM,data:NkEmV8LOwEiN9Sal,iv:mQHBVT6lNoEEEVbl7a5bNN5qoF/LvTyWXQvvkv/z/B0=,tag:IgA5A1nfF91fOBdYxEN71g==,type:str]
|
||||
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
||||
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
||||
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
||||
@@ -43,8 +40,11 @@ ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:4sXQk8zklruC525J279TUUatdDJQ43qweuoPhtpI82
|
||||
NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str]
|
||||
SUPERVISOR_GIT_PULL=ENC[AES256_GCM,data:mg==,iv:KgqMVYj12FjOzWxtA1T0r0pqCDJ6MtHzMjE+4W/W+s4=,tag:czFaOqhHG8nqrQ8AZ8QiGw==,type:str]
|
||||
#ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment]
|
||||
PROXY_URLS=ENC[AES256_GCM,data:nm4B++SkZZgN3p2xru3WrpVA0X6O8yvb45tH/ovF4006zBy28xqVxbsd44Mz6b5FMinjOXRmGwoI/GDWmdJLzBYdpryQ/FhpbzSUpr1ZOjOz+7P0vn2jfBGAB8ksU3i5kuYglud3EyQGFL+v+uooxwrIUCjfzmmB4vCmf7phssKDsK1CqzmdZ1c54ehSu4bRRdmGp9d0+r+j1SpXb/JbZ8LTqUIhLlZXrHFqkCfN1czhFK9IwMVgR00Q4v2YkjaRBME4lVqwk1NwwatbS9Fq8LlzwuT1uKk+T6ZDkFKC8ZoPW1YRqF13X7hFGFXCNRqABRDZ45lqxYQbBoRrWmH2tfMiAmTrIuRsdPM8bZ/Ol5mXSDhs0HyWX2urX+LD65rIOO0zN/lwjXSwh5mwwBdB61akdzsWRyLZsdafuQUmgGul8y0eGMEbFWaty3bdrtAmqtsvHwxD/Dp/gQWScESXvPd1arn55zaXmefOy+ZLwcmx+FAJPpTMXRaq6Y/Z+D1PZZ+Uhu2D6tsAR4VvqqwlUgpsrAFXk6chJzOry8rmmxoMuIj9mXfjG+BqPFhV2oQsKSuIqFQqd/ZidJLO8ZSxA7L+h1eH4cQjcUd2nfzroG8nnKZ+cA8hQMfLuFiMY1I=,iv:nTaNQlC3px/lnodLphnILWbPVnelaUKKOZAFAaHi8MU=,tag:TYkIX1nrc+PKbvvnWYcvbg==,type:str]
|
||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
|
||||
PROXY_URLS_RESIDENTIAL=ENC[AES256_GCM,data:vxRcXQ/8TUTCtr6hKWBD1zVF47GFSfluIHZ8q0tt8SqQOWDdDe2D7Of6boy/kG3lqlpl7TjqMGJ7fLORcr0klKCykQ==,iv:YjegXXtIXm2qr0a3ZHRHxj3L1JoGZ1iQXkVXQupGQ2E=,tag:kahoHRskXbzplZasWOeiig==,type:str]
|
||||
PROXY_URLS_DATACENTER=ENC[AES256_GCM,data:23TgU6oUeO7J+MFkraALQ5/RO38DZ3ib5oYYJr7Lj3KXQSlRsgwA+bJlweI5gcUpFphnPXvmwFGiuL6AeY8LzAQ3bx46dcZa5w9LfKw2PMFt,iv:AGXwYLqWjT5VmU02qqada3PbdjfC0mLK2sPruO0uru8=,tag:Z2IS/JPOqWX+x0LZYwyArA==,type:str]
|
||||
WEBSHARE_DOWNLOAD_URL=ENC[AES256_GCM,data:/N77CFf6tJWCk7HrnBOm2Q1ynx7XoblzfbzJySeCjrxqiu4r+CB90aDkaPahlQKI00DUZih3pcy7WhnjdAwI30G5kJZ3P8H8/R0tP7OBK1wPVbsJq8prQJPFOAWewsS4KWNtSURZPYSCxslcBb7DHLX6ZAjv6A5KFOjRK2N8usR9sIabrCWh,iv:G3Ropu/JGytZK/zKsNGFjjSu3Wt6fvHaAqI9RpUHvlI=,tag:fv6xuS94OR+4xfiyKrYELA==,type:str]
|
||||
PROXY_CONCURRENCY=ENC[AES256_GCM,data:WWpx,iv:4RdNHXPXxFS5Yf1qa1NbaZgXydhKiiiEiMhkhQxD3xE=,tag:6UOQmBqj+9WlcxFooiTL+A==,type:str]
|
||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:9wQ=,iv:QS4VfelUDdaDbIUC8SJBuy09VpiWM9QQcYliQ7Uai+I=,tag:jwkJY95qXPPrgae8RhKPSg==,type:str]
|
||||
#ENC[AES256_GCM,data:RC+t2vqLwLjapdAUql8rQls=,iv:Kkiz3ND0g0MRAgcPJysIYMzSQS96Rq+3YP5yO7yWfIY=,tag:Y6TbZd81ihIwn+U515qd1g==,type:comment]
|
||||
GSC_SERVICE_ACCOUNT_PATH=ENC[AES256_GCM,data:Vki6yHk+gd4n,iv:rxzKvwrGnAkLcpS41EZ097E87NrIpNZGFfl4iXFvr40=,tag:EZkBJpCq5rSpKYVC4H3JHQ==,type:str]
|
||||
GSC_SITE_URL=ENC[AES256_GCM,data:K0i1xRym+laMP6kgOMEfUyoAn2eNgQ==,iv:kyb+grzFq1e5CG/0NJRO3LkSXexOuCK07uJYApAdWsA=,tag:faljHqYjGTgrR/Zbh27/Yw==,type:str]
|
||||
@@ -53,13 +53,18 @@ BING_SITE_URL=ENC[AES256_GCM,data:M33VI97DyxH8gRR3ZUXoXg4QrEv5og==,iv:GxZtwfbBVi
|
||||
#ENC[AES256_GCM,data:OTUMKNkRW0zrupNppXthwE1oieILhNjM+cjx5hFn69g=,iv:48ID2qtSe9ggD2X+G/iUqp3v2uwEc7fZw8lxHIvVXmk=,tag:okBn0Npk1K9dDOFWA/AB1A==,type:comment]
|
||||
GEONAMES_USERNAME=ENC[AES256_GCM,data:UXd/S2TzXPiGmLY=,iv:OMURM5E6SFEsaqroUlH76DEnr7C/ujNk9UQnbWT0hK4=,tag:VsjjS12QDbudiEhdAQ/OCQ==,type:str]
|
||||
CENSUS_API_KEY=ENC[AES256_GCM,data:9RbKlxSD17LqIuuNXaOKSgZ8LnFh9Wbze3XHgpctfV/1TqBMZTIedQ==,iv:WwsmR3HLUEcgUpLliGRaUPhGM9vFNPMGXSAQQ6+9UVc=,tag:R4EMNy5MxxvK0UTaCL0umA==,type:str]
|
||||
#ENC[AES256_GCM,data:SL402gYB8ngjqkrG03FmaA==,iv:I326cYnOWdFnaUwnSfP+s2p9oCDCnqDzUJuPOzSFJc0=,tag:MBW5AqAaq4hTMmNXq1tXKw==,type:comment]
|
||||
R2_LANDING_BUCKET=ENC[AES256_GCM,data:yZXLNQb8yN9nQPdxqmqv61fLWbRYCjjOqQ==,iv:fAwBLC/EuU0lgYOxZSkTagWyeQCdEadjssapxpCEGjA=,tag:VUmuVw76WZAaukp71Desag==,type:str]
|
||||
R2_LANDING_ACCESS_KEY_ID=ENC[AES256_GCM,data:Y6y+U1ayhpFDcoaDjl7hyMVjU3gVvtORAH5gbd+HXbM=,iv:ra9kuch1DT+2tfz140bvxQRIXypsdiUrX1QYQ59gNRI=,tag:Wt85qliUMFvgbvoUrOXT7A==,type:str]
|
||||
R2_LANDING_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:99wB9aKSq2GihW9FOwBSMgHYzNKBHlol2Mf2kg4Ma6Fr4Cr21t/blzPxNQ7YRdeKk6ypFgViXlS4BJz9nC+v0g==,iv:/AmbXtj/uSGcMp+NBhN5tiVb2U56tvO5e1UpG2/ijPo=,tag:Qg2Tt11DUJPyeYcq9iSVnQ==,type:str]
|
||||
R2_ENDPOINT=ENC[AES256_GCM,data:PBWTzUfhc/qVZ4n3GqJdZu8W7Ee0+FpsgikWVxgptQ3BJ2rQ4ewDuEB05inB1Agz1sB42VEBAsTtR3c5waPPRNs=,iv:ILZ0999fsPYYzVQYuIgAxpyystcplnykVoT5RpSEW2w=,tag:FxFOjQ+YcZuLf+jJr2OVFQ==,type:str]
|
||||
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBaUVk0UEVqdmtsM3VzQnpZ\nZjJDZ1lsM0VqWFpVVXUvNzdQcCtHbVJLNjFnCmhna01vTkVBaFQ5ZVlXeGhYNXdH\ncWJ5Qi9PdkxLaHBhQnR3cmtoblkxdEUKLS0tIDhHamY4NXhxOG9YN1NpbTN1aVRh\nOHVKcEN1d0QwQldVTDlBWUU4SDVDWlUKRJU+CTfTzIx6LLKin9sTXAHPVAfiUerZ\nCqYVFncsCJE3TbMI424urQj7kragPoGl1z4++yqAXNTRxfZIY4KTkg==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||
sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmVEticFRVemlzZnlzek4x\nbWJ0d0h5ejJVUk5remo1VkdxNjVpdllqbFhFClc1UXlNd09xVVA5MnltMlN5MWRy\nYUlNRmNybHh1RGdPVC9yWlYrVmRTdkkKLS0tIHBUbU9qSDMrVGVHZDZGSFdpWlBh\nT3NXTGl0SmszaU9hRmU5bXI0cDRoRW8KLvbNYsBEwz+ITKvn7Yn+iNHiRzyyjtQt\no9/HupykJ3WjSdleGz7ZN6UiPGelHp0D/rzSASTYaI1+0i0xZ4PUoQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||
sops_lastmodified=2026-02-26T14:32:28Z
|
||||
sops_mac=ENC[AES256_GCM,data:pyHQHwTtjh7OLiMqbqhUjfrmetEtYS7yB342C/TWfDCwEotWLVwnGWlC4+HIl53pw9+3AgoBVRnW0t86e4kG9O8KyHnk68S9qBcpUsybW3lyGPNXmBydv1W9gQHuK8f/4WGIbkhNxyIToKg9ZAmYWFxNhRKSoYKm5P9Uh7B7CF4=,iv:syrX8VdL3JsDsawvFWbX04Ygcr18hjSSHfEwHkyKETk=,tag:qrhWkh/e+21OKGU2+rCeyg==,type:str]
|
||||
sops_lastmodified=2026-03-05T15:55:19Z
|
||||
sops_mac=ENC[AES256_GCM,data:orLypjurBTYmk3um0bDQV3wFxj1pjCsjOf2D+AZyoIYY88MeY8BjK8mg8BWhmJYlGWqHH1FCpoJS+2SECv2Bvgejqvx/C/HSysA8et5CArM/p/MBbcupLAKOD8bTXorKMRDYPkWpK/snkPToxIZZd7dNj/zSU+OhRp5qLGCHkvM=,iv:eBn93z4DSk8UPHgP/Jf/Kz+3KwoKIQ9Et72pbLFcLP8=,tag:79kzPIKp0rtHGhH1CkXqwg==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
- run: uv run pytest web/tests/ -x -q -p no:faulthandler
|
||||
- run: uv run ruff check web/src/ web/tests/
|
||||
|
||||
# Creates v<N> tag after tests pass. The on-server supervisor polls for new
|
||||
# tags every 60s and deploys automatically. No SSH keys or deploy credentials
|
||||
# needed in CI — only the built-in github.token.
|
||||
# Creates a v{YYYYMMDDHHMM} tag after tests pass on master.
|
||||
# The on-server supervisor polls for new tags every 60s and deploys
|
||||
# automatically. No SSH keys or deploy credentials needed in CI.
|
||||
tag:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,5 +32,6 @@ jobs:
|
||||
run: |
|
||||
git config user.name "CI"
|
||||
git config user.email "ci@noreply"
|
||||
git tag "v${{ github.run_number }}"
|
||||
git push origin "v${{ github.run_number }}"
|
||||
TAG="v$(date -u +%Y%m%d%H%M)"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
stages:
|
||||
- test
|
||||
- tag
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: python:3.12-slim
|
||||
before_script:
|
||||
- pip install uv
|
||||
script:
|
||||
- uv sync
|
||||
- uv run pytest web/tests/ -x -q -p no:faulthandler
|
||||
- uv run ruff check web/src/ web/tests/
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "master"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
|
||||
tag:
|
||||
stage: tag
|
||||
image:
|
||||
name: alpine/git
|
||||
entrypoint: [""]
|
||||
script:
|
||||
- git tag "v${CI_PIPELINE_IID}"
|
||||
- git push "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "v${CI_PIPELINE_IID}"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "master"
|
||||
|
||||
# Deployment is handled by the on-server supervisor (src/padelnomics/supervisor.py).
|
||||
# It polls git every 60s, fetches tags, and deploys only when a new passing tag exists.
|
||||
# No CI secrets needed — zero SSH keys, zero deploy credentials.
|
||||
159
CHANGELOG.md
159
CHANGELOG.md
@@ -6,6 +6,165 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — handle DuckDB catalog naming quirk where `lakehouse.duckdb` uses catalog `lakehouse` instead of `local`, causing SQLMesh logical views to break. Script now auto-detects the catalog via `USE`, and falls back to querying physical tables (`sqlmesh__<schema>.<table>__<hash>`) when views fail.
|
||||
- **Eurostat gas prices extractor** — `nrg_pc_203` filter missing `unit` dimension (API returns both KWH and GJ_GCV); now filters to `KWH`.
|
||||
- **Eurostat labour costs extractor** — `lc_lci_lev` used non-existent `currency` filter dimension; corrected to `unit: EUR`.
|
||||
- **Supervisor transform step** — changed `sqlmesh run` to `sqlmesh plan prod --auto-apply` so new/modified models are detected and applied automatically.
|
||||
|
||||
### Added
|
||||
- **Pipeline diagnostic script** (`scripts/check_pipeline.py`) — read-only script that reports row counts at every layer of the pricing pipeline (staging → foundation → serving), date range analysis, HAVING filter impact, and join coverage. Run on prod to diagnose empty serving tables.
|
||||
- **Extraction card descriptions** — each workflow card on the admin pipeline page now shows a one-line description explaining what the data source is (e.g. "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"). Descriptions defined in `workflows.toml`.
|
||||
- **Running state indicator** — extraction cards show a spinner + "Running" label with a blue-tinted border when an extraction is actively running, replacing the plain Run button. Cards also display the start time with "running..." text.
|
||||
|
||||
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
|
||||
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
|
||||
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
|
||||
- **City cost articles**: venue dot map centered on city lat/lon (zoom 13), navy dots per venue with tooltip showing name + court breakdown (indoor/outdoor).
|
||||
- **Opportunity map** (`/<lang>/opportunity-map`): standalone full-width page with country selector. Circles sized by population, colored by opportunity score (green ≥ 70, amber 40-70, blue < 40). Existing venues shown as gray reference dots.
|
||||
- New `/api` blueprint with 4 JSON endpoints (`/api/markets/countries.json`, `/api/markets/<country>/cities.json`, `/api/markets/<country>/<city>/venues.json`, `/api/opportunity/<country>.json`) — 1-hour public cache headers, all queries against `analytics.duckdb` via `fetch_analytics`.
|
||||
- New SQLMesh serving model `city_venue_locations` exposing venue lat/lon + court counts per city.
|
||||
- `pseo_city_costs_de` serving model: added `lat`/`lon` columns for city map data attributes in baked articles.
|
||||
- Leaflet CSS included on all article pages (5KB, cached). JS loaded dynamically only when a map container is present.
|
||||
- **Individualised article financial calculations with real per-country cost data** — ~30 CAPEX/OPEX calculator fields now scale to each country's actual cost level via Eurostat data, eliminating the identical DE-hardcoded numbers shown for every city globally.
|
||||
- **New Eurostat datasets extracted** (8 new landing files): electricity prices (`nrg_pc_205`), gas prices (`nrg_pc_203`), labour costs (`lc_lci_lev`), and 5 price level index categories from `prc_ppp_ind` (construction, housing, services, misc, government).
|
||||
- `extract/padelnomics_extract/src/padelnomics_extract/eurostat.py`: added 8 dataset entries; added `dataset_code` field support so multiple dict entries can share one Eurostat API endpoint (needed for 5 prc_ppp_ind variants).
|
||||
- **4 new staging models**: `stg_electricity_prices`, `stg_gas_prices`, `stg_labour_costs`, `stg_price_levels` — all read from landing zone with ISO code normalisation (EL→GR, UK→GB).
|
||||
- **New `foundation.dim_countries`** — conformed country dimension (grain: `country_code`). Consolidates country names/slugs and income data previously duplicated in `dim_cities` and `dim_locations` as ~50-line CASE blocks. Computes ~29 calculator cost override columns from Eurostat PLI indices and energy prices relative to DE baseline.
|
||||
- **Refactored `dim_cities`** — removed ~50-line CASE blocks and `country_income` CTE; JOIN `dim_countries` for `country_name_en`, `country_slug`, `median_income_pps`, `income_year`.
|
||||
- **Refactored `dim_locations`** — same refactor as `dim_cities`; income cascade still cascades EU NUTS-2 → US state → `dim_countries` country-level.
|
||||
- **Updated `serving.pseo_city_costs_de`** — JOIN `dim_countries`; 29 new camelCase override columns (`electricity`, `heating`, `rentSqm`, `hallCostSqm`, …, `permitsCompliance`) auto-applied by calculator.
|
||||
- **Updated `serving.planner_defaults`** — JOIN `dim_countries`; same 29 cost columns flow through to the planner API `/api/market-data` endpoint.
|
||||
- **Bulk actions for articles and leads** — checkbox selection + floating action bar on admin articles and leads pages (same pattern as suppliers). Articles: publish, unpublish, toggle noindex, rebuild, delete. Leads: set status, set heat. Re-renders results via HTMX after each action.
|
||||
- **Stripe payment provider** — second payment provider alongside Paddle, switchable via `PAYMENT_PROVIDER=stripe` env var. Existing Paddle subscribers keep working regardless of toggle — both webhook endpoints stay active.
|
||||
- `billing/stripe.py`: full Stripe implementation (Checkout Sessions, Billing Portal, subscription cancel, webhook verification + parsing)
|
||||
- `billing/paddle.py`: extracted Paddle-specific logic from routes.py into its own module
|
||||
- `billing/routes.py`: provider-agnostic dispatch layer — checkout, manage, cancel routes call `_provider().xxx()`
|
||||
- `_payment_js.html`: dual-path JS — conditionally loads Paddle.js SDK, universal `startCheckout()` handles both overlay (Paddle) and redirect (Stripe)
|
||||
- `scripts/setup_stripe.py`: mirrors `setup_paddle.py` — creates 17 products + prices in Stripe, registers webhook endpoint
|
||||
- Migration 0028: `payment_products` table generalizing `paddle_products` with `provider` column; existing Paddle rows copied
|
||||
- `get_price_id()` / `get_all_price_ids()` replace `get_paddle_price()` for provider-agnostic lookups
|
||||
- Stripe config vars: `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`
|
||||
- Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint
|
||||
- Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard)
|
||||
|
||||
### Fixed
|
||||
- **City slug transliteration** — replaced broken inline `REGEXP_REPLACE(LOWER(...), '[^a-z0-9]+', '-')` with new `@slugify` SQLMesh macro that uses `STRIP_ACCENTS` + `ß→ss` pre-replacement. Fixes: `Düsseldorf` → `dusseldorf` (was `d-sseldorf`), `Überlingen` → `uberlingen` (was `-berlingen`). Applied to `dim_venues`, `dim_cities`, `dim_locations`. Python `slugify()` in `core.py` updated to match.
|
||||
- **B2B article market links** — added missing language prefix (`/markets/germany` → `/de/markets/germany` and `/en/markets/germany`). Without the prefix, Quart interpreted `markets` as a language code → 500 error.
|
||||
- **Country overview top-5 city list** — changed ranking from raw `market_score DESC` (which inflated tiny towns with high density scores) to `padel_venue_count DESC` for top cities and `population DESC` for top opportunity cities. Germany now shows Berlin, Hamburg, München instead of Überlingen, Schwaigern.
|
||||
|
||||
### Changed
|
||||
- **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster."
|
||||
- **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
|
||||
- **Proof strip**: live stats bar below hero (business plans created, suppliers, countries, project volume)
|
||||
- **"Sound familiar?" section**: replaces the 5-step journey timeline (3 items said "SOON") with 4 struggling-moment cards from JTBD research
|
||||
- **Feature cards reframed as outcomes**: "60+ Variables" → "Know Your Numbers Inside Out", "6 Analysis Tabs" → "Bank-Ready from Day One", "Sensitivity Analysis" → "Stress-Test Before You Commit", etc.
|
||||
- **"Why Padelnomics" comparison**: 3-column section (DIY Spreadsheet vs. Hired Consultant vs. Padelnomics) from JTBD Competitive Job Map
|
||||
- **FAQ rewritten**: customer-first questions ("How much does it cost to open a padel facility?", "Will a bank accept this?") replace product-internal questions
|
||||
- **Final CTA**: "Your Bank Meeting Is Coming. Be Ready." replaces generic "Start Planning Today"
|
||||
- **Supplier page**: "Is this your sales team?" struggling-moments section, conditional stats display (hides zeros), data-backed proof points replacing anonymous testimonials, ROI math moved above pricing, tier-specific CTAs
|
||||
- **Meta/SEO**: updated page title and description for search intent
|
||||
- All changes in both EN and DE (native-quality German, generisches Maskulinum)
|
||||
|
||||
### Fixed
|
||||
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
|
||||
- `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
|
||||
- `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
|
||||
- `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
|
||||
- `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
|
||||
- `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
|
||||
- `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
|
||||
- CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles
|
||||
|
||||
|
||||
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
|
||||
|
||||
### Changed
|
||||
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
|
||||
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.
|
||||
- **`_forward_lead()` helper** (`web/admin/routes.py`): extracts shared DB logic from `lead_forward` and `lead_forward_htmx` — both routes now call the helper and differ only in response format.
|
||||
- **SQLMesh macros** (`transform/macros/__init__.py`): 5 new macros compress repeated country code patterns across 7 SQL models: `@country_name`, `@country_slug`, `@normalize_eurostat_country`, `@normalize_eurostat_nuts`, `@infer_country_from_coords`.
|
||||
- **Extract helpers** (`extract/utils.py`): `skip_if_current()` compresses cursor-check + early-return pattern (3 extractors); `write_jsonl_atomic()` compresses working-file → JSONL → compress pattern (2 extractors).
|
||||
- **Coding philosophy updated** (`~/.claude/coding_philosophy.md`): added `<compression>` section documenting the workflow, the test ("Did this abstraction make the total codebase smaller?"), and distinction from premature DRY.
|
||||
- **Test suite compression pass** — applied same compression workflow to `web/tests/` (30 files, 13,949 lines). Net result: -197 lines across 11 files.
|
||||
- **`admin_client` fixture** lifted from 7 duplicate definitions into `conftest.py`.
|
||||
- **`mock_send_email` fixture** added to `conftest.py`, replacing 60 inline `with patch("padelnomics.worker.send_email", ...)` blocks across `test_emails.py` (51), `test_waitlist.py` (4), `test_businessplan.py` (2). Each refactored test drops one indentation level.
|
||||
|
||||
### Fixed
|
||||
- **Admin: empty confirm dialog on auto-poll** — `htmx:confirm` handler now guards with `if (!evt.detail.question) return` so auto-poll requests (`hx-trigger="every 5s"`, no `hx-confirm` attribute) no longer trigger an empty dialog every 5 seconds.
|
||||
|
||||
### Changed
|
||||
- **Admin: styled confirm dialog for all destructive actions** — replaced all native `window.confirm()` calls with the existing `#confirm-dialog` styled `<dialog>`. A new global `htmx:confirm` handler intercepts HTMX confirmation prompts and shows the dialog; form-submit buttons on affiliate pages were updated to use `confirmAction()`. Affected: pipeline Transform tab (Run Transform, Run Export, Run Full Pipeline), pipeline Overview tab (Run extractor), affiliate product delete, affiliate program delete (both form and list variants).
|
||||
- **Pipeline tabs: no scrollbar** — added `scrollbar-width: none` and `::-webkit-scrollbar { display: none }` to `.pipeline-tabs` to suppress the spurious horizontal scrollbar on narrow viewports.
|
||||
|
||||
### Fixed
|
||||
- **Stale-tier failures no longer exhaust the next proxy tier** — with parallel workers, threads that fetched a proxy just before tier escalation reported failures after the tier changed, immediately blowing through the new tier's circuit breaker before it ever got tried (Rayobyte was skipped entirely). `record_failure(proxy_url)` now checks which tier the proxy belongs to and ignores the circuit breaker when the proxy is from an already-escalated tier.
|
||||
|
||||
- **Proxy URL scheme validation in `load_proxy_tiers()`** — URLs in `PROXY_URLS_DATACENTER` / `PROXY_URLS_RESIDENTIAL` that are missing an `http://` or `https://` scheme are now logged as a warning and skipped, rather than being passed through and causing SSL handshake failures or connection errors at request time. Also fixed a missing `http://` prefix in the dev `.env` `PROXY_URLS_DATACENTER` entry.
|
||||
|
||||
### Changed
|
||||
- **Unified confirm dialog — pure HTMX `hx-confirm` + `<form method="dialog">`** — eliminated the `confirmAction()` JS function and the duplicate `cloneNode` hack. All confirmation prompts now go through a single `showConfirm()` Promise-based function called by the `htmx:confirm` interceptor. The dialog HTML uses `<form method="dialog">` for native close semantics (`returnValue` is `"ok"` or `"cancel"`), removing the need to clone and replace buttons on every invocation. All 12 Padelnomics call sites converted from `onclick=confirmAction(...)` to `hx-boost="true"` + `hx-confirm="..."` on the submit button. Pipeline trigger endpoints updated to treat `HX-Boosted: true` requests as non-HTMX (returning a redirect rather than an inline partial) so boosted form submissions flow through the normal redirect cycle. Same changes applied to BeanFlows and the quart-saas-boilerplate template.
|
||||
- `web/src/padelnomics/admin/templates/admin/base_admin.html`: replaced dialog `<div>` with `<form method="dialog">`, replaced `confirmAction()` + inline `htmx:confirm` handler with unified `showConfirm()` + single `htmx:confirm` listener
|
||||
- `web/src/padelnomics/admin/pipeline_routes.py`: `pipeline_trigger_extract` and `pipeline_trigger_transform` now exclude `HX-Boosted: true` from the HTMX partial path
|
||||
- 12 templates updated: `pipeline.html`, `partials/pipeline_extractions.html`, `affiliate_form.html`, `affiliate_program_form.html`, `partials/affiliate_program_results.html`, `partials/affiliate_row.html`, `generate_form.html`, `articles.html`, `audience_contacts.html`, `template_detail.html`, `partials/scenario_results.html`
|
||||
- Same changes mirrored to BeanFlows and quart-saas-boilerplate template
|
||||
|
||||
- **Per-proxy dead tracking in tiered cycler** — `make_tiered_cycler` now accepts a `proxy_failure_limit` parameter (default 3). Individual proxies that hit the limit are marked dead and permanently skipped by `next_proxy()`. If all proxies in the active tier are dead, `next_proxy()` auto-escalates to the next tier without needing the tier-level threshold. `record_failure(proxy_url)` and `record_success(proxy_url)` accept an optional `proxy_url` argument for per-proxy tracking; callers without `proxy_url` are fully backward-compatible. New `dead_proxy_count()` callable exposed for monitoring.
|
||||
- `extract/padelnomics_extract/src/padelnomics_extract/proxy.py`: added per-proxy state (`proxy_failure_counts`, `dead_proxies`), updated `next_proxy`/`record_failure`/`record_success`, added `dead_proxy_count`
|
||||
- `extract/padelnomics_extract/src/padelnomics_extract/playtomic_tenants.py`: `_fetch_page_via_cycler` passes `proxy_url` to `record_success`/`record_failure`
|
||||
- `extract/padelnomics_extract/src/padelnomics_extract/playtomic_availability.py`: `_worker` returns `(proxy_url, result)` tuple; serial loops in `extract` and `extract_recheck` capture `proxy_url` before passing to `record_success`/`record_failure`
|
||||
- `web/tests/test_supervisor.py`: 11 new tests in `TestTieredCyclerDeadProxyTracking` covering dead proxy skipping, auto-escalation, `dead_proxy_count`, backward compat, and thread safety
|
||||
|
||||
### Added
|
||||
- **Visual upgrades for longform articles** — 4 reusable CSS article components added to `input.css` and applied across 6 cornerstone articles (EN + DE):
|
||||
- `article-timeline`: horizontal numbered phase diagram with connecting lines; collapses to vertical stack on mobile. Replaces ASCII art code blocks in build guide articles.
|
||||
- `article-callout` (warning/tip/info variants): left-bordered callout box with icon, title, and body. Replaces `>` blockquotes and bold-text warnings in build and risk guides.
|
||||
- `article-cards`: 2-column card grid with colored accent bars (success/failure/neutral/established/growth/emerging). Replaces sequential bold-text pattern paragraphs in build, risk, and location guides.
|
||||
- `severity` pills: inline colored badge for High/Medium-High/Medium/Low-Medium/Low. Applied to risk overview tables in both risk guide articles.
|
||||
- Articles updated: `padel-hall-build-guide-en`, `padel-halle-bauen-de`, `padel-hall-investment-risks-en`, `padel-halle-risiken-de`, `padel-hall-location-guide-en`, `padel-standort-analyse-de`
|
||||
|
||||
- **Pipeline Transform tab + live extraction status** — new "Transform" tab in the pipeline admin with status cards for SQLMesh transform and export-serving tasks, a "Run Full Pipeline" button, and a recent run history table. The Overview tab now auto-polls every 5 s while an extraction task is pending and stops automatically when quiet. Per-extractor "Run" buttons use HTMX in-place updates instead of redirects. The header "Run Pipeline" button now enqueues the full ELT pipeline (extract → transform → export) instead of extraction only. Three new worker task handlers: `run_transform` (sqlmesh plan prod --auto-apply, 2 h timeout), `run_export` (export_serving.py, 10 min timeout), `run_pipeline` (sequential, stops on first failure). Concurrency guard prevents double-enqueuing the same step.
|
||||
- `web/src/padelnomics/worker.py`: `handle_run_transform`, `handle_run_export`, `handle_run_pipeline`
|
||||
- `web/src/padelnomics/admin/pipeline_routes.py`: `_render_overview_partial()`, `_fetch_pipeline_tasks()`, `_format_duration()`, `pipeline_transform()`, `pipeline_trigger_transform()`; `pipeline_trigger_extract()` now HTMX-aware
|
||||
- `web/src/padelnomics/admin/templates/admin/pipeline.html`: pulse animation on `.status-dot.running`, Transform tab button, rewired header button
|
||||
- `web/src/padelnomics/admin/templates/admin/partials/pipeline_overview.html`: self-polling wrapper, HTMX Run buttons
|
||||
- `web/src/padelnomics/admin/templates/admin/partials/pipeline_transform.html`: new file
|
||||
|
||||
- **Affiliate programs management** — centralised retailer config (`affiliate_programs` table) with URL template + tracking tag + commission %. Products now use a program dropdown + product identifier (e.g. ASIN) instead of manually baking full URLs. URL is assembled at redirect time via `build_affiliate_url()`, so changing a tag propagates instantly to all products. Legacy products (baked `affiliate_url`) continue to work via fallback. Amazon OneLink configured in the Associates dashboard handles geo-redirect to local marketplaces — no per-country programs needed.
|
||||
- `web/src/padelnomics/migrations/versions/0027_affiliate_programs.py`: `affiliate_programs` table, nullable `program_id` + `product_identifier` columns on `affiliate_products`, seeds "Amazon" program, backfills ASINs from existing URLs
|
||||
- `web/src/padelnomics/affiliate.py`: `get_all_programs()`, `get_program()`, `get_program_by_slug()`, `build_affiliate_url()`; `get_product()` JOINs program for redirect assembly; `_parse_product()` extracts `_program` sub-dict
|
||||
- `web/src/padelnomics/app.py`: `/go/<slug>` uses `build_affiliate_url()` — program-based products get URLs assembled at redirect time
|
||||
- `web/src/padelnomics/admin/routes.py`: program CRUD routes (list, new, edit, delete — delete blocked if products reference the program); product form updated to program dropdown + identifier; `retailer` auto-populated from program name
|
||||
- New templates: `admin/affiliate_programs.html`, `admin/affiliate_program_form.html`, `admin/partials/affiliate_program_results.html`
|
||||
- Updated templates: `admin/affiliate_form.html` (program dropdown + JS toggle), `admin/base_admin.html` (Programs subnav tab)
|
||||
- 15 new tests in `web/tests/test_affiliate.py` (41 total)
|
||||
|
||||
### Fixed
|
||||
- **Data Platform admin view showing stale/zero row counts** — Docker web containers were mounting `/opt/padelnomics/data` (stale copy) instead of `/data/padelnomics` (live supervisor output). Fixed volume mount in all 6 containers (blue/green × app/worker/scheduler) and added `LANDING_DIR=/app/data/pipeline/landing` so extraction stats and landing zone file stats are visible to the web app.
|
||||
- **`workflows.toml` never found in dev** — `_REPO_ROOT` in `pipeline_routes.py` used `parents[5]` (one level too far up) instead of `parents[4]`. Workflow schedules now display correctly on the pipeline overview tab in dev.
|
||||
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.
|
||||
|
||||
### Added
|
||||
- **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/<slug>` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded.
|
||||
- `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema
|
||||
- `web/src/padelnomics/affiliate.py`: `get_product()`, `get_products_by_category()`, `get_all_products()`, `log_click()`, `hash_ip()`, `get_click_stats()`, `get_click_counts()`, `get_distinct_retailers()`
|
||||
- `web/src/padelnomics/content/routes.py`: `PRODUCT_RE`, `PRODUCT_GROUP_RE`, `bake_product_cards()` — chained after `bake_scenario_cards()` in `generate_articles()` and `preview_article()`
|
||||
- `web/src/padelnomics/app.py`: `/go/<slug>` route with rate limiting (60/min per IP) and referer-based article/language extraction
|
||||
- `web/src/padelnomics/admin/routes.py`: affiliate CRUD routes + `bake_product_cards()` chained in article rebuild flows
|
||||
- New templates: `partials/product_card.html`, `partials/product_group.html`, `admin/affiliate_products.html`, `admin/affiliate_form.html`, `admin/affiliate_dashboard.html`, `admin/partials/affiliate_results.html`, `admin/partials/affiliate_row.html`
|
||||
- `locales/en.json` + `locales/de.json`: 6 new affiliate i18n keys
|
||||
- `data/content/articles/`: 10 new German equipment review scaffolds (rackets, balls, shoes, accessories, gifts)
|
||||
- 26 tests in `web/tests/test_affiliate.py`
|
||||
|
||||
### Added
|
||||
- **Three-tier proxy system** for extraction pipeline: free (Webshare auto-fetched) → datacenter (`PROXY_URLS_DATACENTER`) → residential (`PROXY_URLS_RESIDENTIAL`). Webshare free proxies are now auto-fetched from their download API on each run — no more manually copying stale proxy lists.
|
||||
- `proxy.py`: added `fetch_webshare_proxies()` (stdlib urllib, bounded read + timeout), `load_proxy_tiers()` (assembles N tiers from env), generalised `make_tiered_cycler()` to accept `list[list[str]]` with N-level escalation. Exposes `is_exhausted()`, `active_tier_index()`, `tier_count()`.
|
||||
- `playtomic_availability.py`: both `extract()` and `extract_recheck()` now use `load_proxy_tiers()` + N-tier cycler. `_fetch_venues_parallel` `fallback_urls` param removed. `is_fallback_active()` replaced by `is_exhausted()`.
|
||||
- `playtomic_tenants.py`: uses `load_proxy_tiers()` flattened for simple round-robin.
|
||||
|
||||
### Changed
|
||||
- **Env vars renamed** (breaking): `PROXY_URLS` → removed, `PROXY_URLS_FALLBACK` → removed. New vars: `WEBSHARE_DOWNLOAD_URL`, `PROXY_URLS_DATACENTER`, `PROXY_URLS_RESIDENTIAL`.
|
||||
|
||||
### Added
|
||||
- **Phase 2a — NUTS-1 regional income differentiation** (`opportunity_score`): Munich and Berlin no longer share the same income figure as Chemnitz.
|
||||
- `eurostat.py`: added `nama_10r_2hhinc` dataset config (NUTS-2 cube with NUTS-1 entries); filter params now appended to API URL so the server pre-filters the large cube before download (also makes `ilc_di03` requests smaller).
|
||||
|
||||
@@ -25,6 +25,7 @@ WORKDIR /app
|
||||
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
||||
COPY --from=build --chown=appuser:appuser /app .
|
||||
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
|
||||
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
|
||||
USER appuser
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV DATABASE_PATH=/app/data/app.db
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Padelnomics — Project Tracker
|
||||
|
||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||
> Last updated: 2026-02-27 (Phase 2b — EU NUTS-2 spatial join + US state income).
|
||||
> Last updated: 2026-02-28 (Affiliate programs management — centralised retailer config + URL template assembly).
|
||||
|
||||
---
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
- [x] Boost purchases (logo, highlight, verified, card color, sticky week/month)
|
||||
- [x] Credit pack purchases (25/50/100/250)
|
||||
- [x] Supplier subscription tiers (Basic free / Growth €199 / Pro €499, monthly + annual)
|
||||
- [x] **Stripe payment provider** — env-var toggle (`PAYMENT_PROVIDER=paddle|stripe`), Stripe Checkout Sessions + Billing Portal + webhook handling, `payment_products` table generalizes `paddle_products`, dual-path JS templates, `billing/paddle.py` + `billing/stripe.py` dispatch pattern, `setup_stripe.py` product creation script
|
||||
- [x] **Feature flags** (DB-backed, migration 0019) — `is_flag_enabled()` + `feature_gate()` decorator replace `WAITLIST_MODE`; 5 flags (markets, payments, planner_export, supplier_signup, lead_unlock); admin UI at `/admin/flags` with toggle
|
||||
- [x] **Pricing overhaul** — Basic free (no Paddle sub), card color €59, BP PDF €149; supplier page restructured value-first (why → guarantee → leads → social proof → pricing); all CTAs "Get Started Free"; static ROI line; credits-only callout
|
||||
- [x] **Lead-Back Guarantee** (migration 0020) — 1-click credit refund for non-responding leads (3–30 day window); `refund_lead_guarantee()` in credits.py; "Lead didn't respond" button on unlocked lead cards
|
||||
@@ -132,6 +133,8 @@
|
||||
- [x] **pSEO article noindex** — `noindex` column on articles (migration 0025), `NOINDEX_THRESHOLDS` per-template lambdas in `content/__init__.py`, robots meta tag in `article_detail.html`, sitemap exclusion, pSEO dashboard count card + article row badge; 20 tests
|
||||
- [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row
|
||||
- [x] **Email-gated report PDF** — `reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/`
|
||||
- [x] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/<slug>` click-tracking redirect (302, GDPR daily-rotated IP hash, rate-limited); admin CRUD with live preview, HTMX filter/search, status toggle; click stats dashboard (pure CSS charts); 10 German equipment review article scaffolds; 26 tests
|
||||
- [x] **Affiliate programs management** — `affiliate_programs` table centralises retailer configs (URL template, tracking tag, commission %); product form uses program dropdown + product identifier (ASIN etc.); `build_affiliate_url()` assembles at redirect time; legacy baked-URL products still work; admin CRUD (delete blocked if products reference program); Amazon OneLink for multi-marketplace; article frontmatter preview bug fixed; 41 tests
|
||||
|
||||
### SEO & Legal
|
||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||
@@ -155,6 +158,7 @@
|
||||
- [x] Padel racket SVG logo/favicon
|
||||
- [x] Feedback widget (HTMX POST, rate-limited)
|
||||
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
|
||||
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
|
||||
|
||||
---
|
||||
|
||||
@@ -243,7 +247,6 @@
|
||||
|
||||
### Marketing & Content
|
||||
- [ ] LinkedIn presence (ongoing — founder posts, thought leadership)
|
||||
- [ ] "Wirecutter for padel" affiliate site (racket reviews, gear guides)
|
||||
- [ ] "The Padel Business Report" newsletter
|
||||
- [ ] Equipment supplier affiliate partnerships (€500–1,000/lead or 5%)
|
||||
- [ ] Padel podcasts (guest appearances)
|
||||
|
||||
88
data/content/articles/beste-padelschlaeger-de.md
Normal file
88
data/content/articles/beste-padelschlaeger-de.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: "Die besten Padelschläger 2026: Unser ausführlicher Vergleich"
|
||||
slug: beste-padelschlaeger-de
|
||||
language: de
|
||||
url_path: /beste-padelschlaeger-2026
|
||||
meta_description: "Welcher Padelschläger ist der beste 2026? Wir haben die wichtigsten Modelle für Anfänger, Fortgeschrittene und Profis getestet und verglichen."
|
||||
---
|
||||
|
||||
# Die besten Padelschläger 2026: Unser ausführlicher Vergleich
|
||||
|
||||
<!-- TODO: Einleitung mit Hauptkeyword und USP dieser Seite (200–300 Wörter) -->
|
||||
|
||||
Wer einen neuen Padelschläger kaufen will, steht vor einer unüberschaubaren Auswahl. Mehr als 50 Marken, Hunderte von Modellen — und kein einziges unabhängiges Testlabor. Wir haben die meistverkauften und meistempfohlenen Schläger zusammengetragen und nach drei Kriterien bewertet: Spielgefühl, Haltbarkeit und Preis-Leistungs-Verhältnis.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Top-Empfehlungen
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Testsieger im Detail
|
||||
|
||||
<!-- TODO: Ausführliche Besprechung der Top 3–5 Modelle, je 300–500 Wörter pro Schläger -->
|
||||
|
||||
### Platz 1: [Produktname einfügen]
|
||||
|
||||
[product:platzhalter-schlaeger-1-amazon]
|
||||
|
||||
<!-- TODO: Erfahrungsbericht + Vor- und Nachteile im Prosatext -->
|
||||
|
||||
### Platz 2: [Produktname einfügen]
|
||||
|
||||
[product:platzhalter-schlaeger-2-amazon]
|
||||
|
||||
### Platz 3: [Produktname einfügen]
|
||||
|
||||
[product:platzhalter-schlaeger-3-amazon]
|
||||
|
||||
---
|
||||
|
||||
## So haben wir getestet
|
||||
|
||||
<!-- TODO: Kurze Beschreibung der Testmethodik (2–3 Absätze) -->
|
||||
|
||||
---
|
||||
|
||||
## Kaufberatung: Welcher Schläger passt zu mir?
|
||||
|
||||
<!-- TODO: Entscheidungsbaum / Tabelle nach Spielertyp -->
|
||||
|
||||
| Spielertyp | Empfohlene Form | Empfohlenes Gewicht |
|
||||
|---|---|---|
|
||||
| Anfänger | Rund | 355–365 g |
|
||||
| Allspieler | Tropfen | 360–370 g |
|
||||
| Fortgeschrittener | Diamant | 365–380 g |
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie oft sollte man einen Padelschläger wechseln?</summary>
|
||||
|
||||
<!-- TODO: Antwort (50–100 Wörter) -->
|
||||
|
||||
Bei regelmäßigem Spielen (2–3 Mal pro Woche) empfehlen wir einen Wechsel alle 12 bis 18 Monate. Der größte Qualitätsverlust entsteht nicht durch sichtbare Schäden, sondern durch den Abbau der Schaumstoffkerns, der das Spielgefühl verändert.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Was kostet ein guter Padelschläger?</summary>
|
||||
|
||||
<!-- TODO: Preisklassen-Überblick -->
|
||||
|
||||
Gute Einstiegsschläger gibt es ab 50 Euro. Für Fortgeschrittene empfehlen wir 100–200 Euro, für ambitionierte Spieler 200–350 Euro. Über 400 Euro kostet nur das Pro-Segment, das für die meisten Freizeitspieler überdimensioniert ist.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Runder oder Diamant-Schläger — was ist besser?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Runde Schläger verzeihen mehr Fehlschläge und eignen sich für Anfänger und defensive Spieler. Diamant-Schläger liefern mehr Power und werden von Angriffsspielern bevorzugt. Für die meisten Freizeitspieler ist eine Tropfen- oder runde Form die sicherere Wahl.
|
||||
|
||||
</details>
|
||||
69
data/content/articles/padel-ausruestung-anfaenger-de.md
Normal file
69
data/content/articles/padel-ausruestung-anfaenger-de.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: "Padel-Ausrüstung für Anfänger: Was brauche ich wirklich?"
|
||||
slug: padel-ausruestung-anfaenger-de
|
||||
language: de
|
||||
url_path: /padel-ausruestung-anfaenger
|
||||
meta_description: "Was braucht man für Padel? Unser Ausrüstungsguide für Einsteiger — von Schläger und Schuhen bis zur Schutztasche. Was ist unverzichtbar, was ist Luxus?"
|
||||
---
|
||||
|
||||
# Padel-Ausrüstung für Anfänger: Was brauche ich wirklich?
|
||||
|
||||
<!-- TODO: Einleitung — klare Orientierung für Einsteiger -->
|
||||
|
||||
Padel ist im Vergleich zu vielen anderen Sportarten günstig einzusteigen. Wer zum ersten Mal auf den Court geht, braucht eigentlich nur drei Dinge: einen Schläger, die richtigen Schuhe und Bälle. Der Rest ist komfortsteigerndes Zubehör — notwendig wird es erst, wenn man ernsthafter spielt.
|
||||
|
||||
---
|
||||
|
||||
## Die unverzichtbare Grundausstattung
|
||||
|
||||
### 1. Schläger
|
||||
|
||||
[product:platzhalter-anfaenger-schlaeger-amazon]
|
||||
|
||||
<!-- TODO: 1–2 Absätze zum Einstiegsschläger -->
|
||||
|
||||
### 2. Schuhe
|
||||
|
||||
[product:platzhalter-padelschuh-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### 3. Bälle
|
||||
|
||||
[product:platzhalter-ball-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Was kann ich mir zunächst sparen?
|
||||
|
||||
<!-- TODO: Schläger-Tasche, Griffband, Sportbrille — wann sinnvoll? -->
|
||||
|
||||
---
|
||||
|
||||
## Das komplette Anfänger-Set: Unsere Empfehlung
|
||||
|
||||
[product-group:accessory]
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie viel kostet ein komplettes Padel-Starterpaket?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Für rund 150 Euro bekommt man einen soliden Anfängerschläger (60–90 €), passende Padelschuhe (50–70 €) und eine Dose Bälle (6–10 €). Alles darüber hinaus ist optional.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kann ich mit geliehener Ausrüstung starten?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ja, für die ersten Einheiten ist das sinnvoll. Die meisten Padel-Center verleihen Schläger für 2–5 Euro pro Einheit. Wer mehr als 3–4 Mal spielen will, lohnt sich ein eigener Schläger — schon allein wegen des vertrauten Spielgefühls.
|
||||
|
||||
</details>
|
||||
@@ -91,6 +91,8 @@ Die Bilanz am ersten Betriebstag: Aktiva (Anlagevermögen nach CAPEX, Anfangsliq
|
||||
|
||||
## KfW-Förderprogramme für Padelhallen
|
||||
|
||||
Abschnitt 9 des Gliederungsrahmens verlangt: Welche Förderprogramme wurden geprüft? Hier ist die Antwort, die Ihr Businessplan liefern muss.
|
||||
|
||||
Die KfW bietet mehrere Programme, die für Padelhallen-Projekte relevant sein können. Wichtig: KfW-Kredite werden nicht direkt bei der KfW beantragt, sondern über die Hausbank. Die Hausbank leitet den Antrag weiter und trägt einen Teil des Ausfallrisikos mit — was erklärt, warum sie ein starkes Eigeninteresse an der Qualität des Businessplans hat.
|
||||
|
||||
**KfW Unternehmerkredit (037/047)**
|
||||
@@ -129,7 +131,7 @@ Was passiert, wenn die Auslastung 10 Prozentpunkte unter Plan liegt? Wenn die Ba
|
||||
|
||||
### 4. Unvollständiger CAPEX
|
||||
|
||||
Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (3–6 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Bankstandard: 10 Prozent Contingency auf den Rohbau). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es.
|
||||
Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (3–6 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Mindestpuffer: 10 Prozent auf den Rohbau — bei Sportstättenumbauten realistisch eher 15–20 Prozent). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es.
|
||||
|
||||
### 5. KfW nicht adressiert
|
||||
|
||||
@@ -148,7 +150,7 @@ Fragen, die Sie sich vor der Bürgschaftsübernahme stellen sollten:
|
||||
- Gibt es Vermögenswerte, die ich herauslösen kann (z.B. durch Schenkung an Ehepartner vor Gründung — hier unbedingt Rechtsberatung einholen, da Anfechtungsrisiken bestehen)?
|
||||
- Wie viele Monate Verlustbetrieb kann ich aus eigenen Mitteln abfedern?
|
||||
|
||||
Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen.
|
||||
Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen. Das spüren Banken.
|
||||
|
||||
---
|
||||
|
||||
@@ -158,4 +160,10 @@ Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten
|
||||
|
||||
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
|
||||
|
||||
[→ Businessplan erstellen]
|
||||
[→ Businessplan erstellen](/de/planner)
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
|
||||
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ The formula:
|
||||
DSCR = operating cash flow ÷ annual debt service (interest + principal)
|
||||
```
|
||||
|
||||
The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.20–1.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and he'll be more conservative than you.
|
||||
The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.20–1.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and they'll be more conservative than you.
|
||||
|
||||
The other hard constraint is **equity contribution** (*Eigenkapitalquote*): banks typically expect the founder to put in 20–30% of total investment. KfW subsidy programs can partly substitute for equity (more on that below), but they never replace it entirely. Coming to the table with 10% equity rarely works.
|
||||
|
||||
@@ -89,6 +89,8 @@ The balance sheet on Day 1: assets (fixed assets after CAPEX, opening cash) vers
|
||||
|
||||
## KfW Subsidy Programs for Padel Hall Projects
|
||||
|
||||
Section 9 of the business plan framework above asks which financing programs have been evaluated. Here's the answer your plan needs to provide.
|
||||
|
||||
KfW (Germany's state development bank) offers several programs relevant to padel hall construction and launch. One crucial operational detail: KfW loans are not applied for directly at KfW. They're applied for through your *Hausbank* (house bank), which passes the application to KfW and shares a portion of the default risk. This is precisely why your Hausbank cares so much about the quality of your business plan — they're on the hook too.
|
||||
|
||||
**KfW Unternehmerkredit (programs 037/047)**
|
||||
@@ -109,7 +111,7 @@ Each German state (*Bundesland*) runs its own SME and startup lending programs t
|
||||
- Hamburg: IFB Hamburg
|
||||
- Saxony: Sächsische Aufbaubank (SAB)
|
||||
|
||||
These programs are overlooked in the majority of business plans we've reviewed — despite the fact that combining them with KfW can meaningfully reduce the equity burden.
|
||||
These programs are overlooked in the majority of business plans we've reviewed — even though combining them with KfW can meaningfully reduce the equity burden.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,7 +131,7 @@ What happens if utilization comes in 10 percentage points below plan? If constru
|
||||
|
||||
### 4. Incomplete CAPEX
|
||||
|
||||
Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (3–6 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (the industry standard is 10% of raw construction costs). Forget these, and you're underfunded from Day 1.
|
||||
Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (3–6 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (minimum 10% of raw construction costs — 15–20% is more realistic for sports hall conversions). Forget these, and you're underfunded from Day 1.
|
||||
|
||||
### 5. No mention of KfW or subsidy programs
|
||||
|
||||
@@ -148,7 +150,7 @@ Questions worth answering before you proceed:
|
||||
- Are there assets that could be structured outside the exposure (specialist legal advice is essential here, as pre-signing asset transfers can be challenged under German insolvency law)?
|
||||
- How many months of operating losses could I absorb from personal resources?
|
||||
|
||||
A founder who has worked through these questions has taken the project seriously. That comes across in a bank conversation.
|
||||
A founder who has worked through these questions has taken the project seriously. Banks can tell.
|
||||
|
||||
---
|
||||
|
||||
@@ -158,4 +160,10 @@ A bankable business plan depends on the quality of the financial model behind it
|
||||
|
||||
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
|
||||
|
||||
[→ Generate your business plan]
|
||||
[→ Generate your business plan](/en/planner)
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
|
||||
<a href="/quote" class="btn">Request a Quote</a>
|
||||
</div>
|
||||
|
||||
67
data/content/articles/padel-geschenke-de.md
Normal file
67
data/content/articles/padel-geschenke-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padel-Geschenke: Die besten Ideen für Padelbegeisterte"
|
||||
slug: padel-geschenke-de
|
||||
language: de
|
||||
url_path: /padel-geschenke
|
||||
meta_description: "Padel-Geschenke für Geburtstage, Weihnachten oder als Überraschung. Von der günstigen Kleinigkeit bis zum hochwertigen Schläger — für jeden Budget."
|
||||
---
|
||||
|
||||
# Padel-Geschenke: Die besten Ideen für Padelbegeisterte
|
||||
|
||||
<!-- TODO: Einleitung — Padel boomt, Geschenkideen gefragt -->
|
||||
|
||||
Padel ist der am schnellsten wachsende Sport Europas — und viele haben gerade erst damit begonnen. Wer einem Padel-Fan ein Geschenk machen will, steht vor der Frage: Was fehlt ihm noch? Dieser Guide listet die besten Ideen nach Preisklassen, vom kleinen Mitbringsel bis zum Wunschschläger.
|
||||
|
||||
---
|
||||
|
||||
## Geschenke unter 15 Euro
|
||||
|
||||
[product-group:grip]
|
||||
|
||||
<!-- TODO: Griffband, Bälle, kleine Accessoires -->
|
||||
|
||||
---
|
||||
|
||||
## Geschenke unter 50 Euro
|
||||
|
||||
[product-group:accessory]
|
||||
|
||||
<!-- TODO: Sporttasche, Cover, Trainingszubehör -->
|
||||
|
||||
---
|
||||
|
||||
## Geschenke unter 100 Euro
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
[product:platzhalter-schuh-amazon]
|
||||
|
||||
---
|
||||
|
||||
## Das perfekte Geschenk: Ein neuer Schläger
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
<!-- TODO: Hinweis auf Wunschliste / Amazon-Wunschliste-Tipp -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie finde ich heraus, welcher Schläger passt?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Fragen Sie die beschenkte Person nach ihrem aktuellen Modell oder lassen Sie sie aus einer Empfehlungsliste wählen. Schläger sind sehr persönlich — eine Gutscheinkarte für einen Fachhandel ist oft die sicherste Option.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gibt es Padel-Geschenksets?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Einige Marken bieten Starter-Sets an (Schläger + Bälle + Cover). Diese sind im Vergleich zum Einzelkauf oft günstiger und eignen sich als Komplett-Einstiegsgeschenk für Neuspieler.
|
||||
|
||||
</details>
|
||||
@@ -17,21 +17,54 @@ This guide walks through all five phases and 23 steps between your initial marke
|
||||
|
||||
## The 5 Phases at a Glance
|
||||
|
||||
```
|
||||
Phase 1 Phase 2 Phase 3 Phase 4 Phase 5
|
||||
Feasibility → Planning & → Construction → Pre- → Operations &
|
||||
& Concept Design / Conversion Opening Optimization
|
||||
|
||||
Month 1–3 Month 3–6 Month 6–12 Month 10–13 Ongoing
|
||||
|
||||
Steps 1–5 Steps 6–11 Steps 12–16 Steps 17–20 Steps 21–23
|
||||
```
|
||||
<div class="article-timeline">
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">1</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Feasibility & Concept</div>
|
||||
<div class="article-timeline__subtitle">Market research, concept, site scouting</div>
|
||||
<div class="article-timeline__meta">Month 1–3 · Steps 1–5</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">2</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Planning & Design</div>
|
||||
<div class="article-timeline__subtitle">Architect, permits, financing</div>
|
||||
<div class="article-timeline__meta">Month 3–6 · Steps 6–11</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">3</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Construction</div>
|
||||
<div class="article-timeline__subtitle">Build, courts, IT systems</div>
|
||||
<div class="article-timeline__meta">Month 6–12 · Steps 12–16</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">4</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Pre-Opening</div>
|
||||
<div class="article-timeline__subtitle">Hiring, marketing, soft launch</div>
|
||||
<div class="article-timeline__meta">Month 10–13 · Steps 17–20</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">5</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Operations</div>
|
||||
<div class="article-timeline__subtitle">Revenue streams, optimization</div>
|
||||
<div class="article-timeline__meta">Ongoing · Steps 21–23</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feasibility and Concept (Months 1–3)
|
||||
|
||||
This is the most important phase and the one where projects most often go wrong in one of two directions: either stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later.
|
||||
This is the most important phase — and where projects most often go wrong in one of two directions: stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later.
|
||||
|
||||
### Step 1: Market Research
|
||||
|
||||
@@ -49,7 +82,7 @@ Good market research won't guarantee success, but it will protect you from the m
|
||||
|
||||
Your market research should drive your concept. How many courts? Which customer segments — competitive recreational players, club training, corporate wellness, broad community use? What service level — a pure booking facility or a full-concept venue with lounge, bar, pro shop, and coaching program?
|
||||
|
||||
Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail this down before moving to site selection.
|
||||
Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail the concept before moving to site selection.
|
||||
|
||||
### Step 3: Location Scouting
|
||||
|
||||
@@ -105,7 +138,12 @@ Deliverables from this phase:
|
||||
- **MEP design (mechanical, electrical, plumbing):** Heating, ventilation, air conditioning, electrical, drainage — typically the most expensive trade package in a sports hall conversion
|
||||
- **Fire safety strategy**
|
||||
|
||||
> **The most expensive planning mistake in padel hall builds:** underestimating HVAC complexity and budget. Large indoor courts need precise temperature and humidity control — not just for player comfort, but for playing surface longevity and air quality. Courts installed in a poorly climate-controlled building will degrade faster and generate complaints. Budget for it properly from the start, not as a value-engineering target.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">The most expensive planning mistake in padel hall builds</span>
|
||||
<p>Underestimating HVAC complexity and budget. Large indoor courts need precise temperature and humidity control — not just for player comfort, but for playing surface longevity and air quality. Courts installed in a poorly climate-controlled building will degrade faster and generate complaints. Budget for it properly from the start, not as a value-engineering target.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Step 8: Court Supplier Selection
|
||||
|
||||
@@ -125,7 +163,7 @@ Approach lenders with your full business plan. Typical capital structure for pad
|
||||
- 50–70% debt (bank loan)
|
||||
- 30–50% equity (own funds, silent partners, shareholder loans)
|
||||
|
||||
What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. See the companion article on investment risks for a full treatment of personal guarantee exposure.
|
||||
What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. The companion article on investment risks covers personal guarantee exposure in full.
|
||||
|
||||
Investigate public funding programs: development bank loans, regional sports infrastructure grants, and municipal co-investment schemes can reduce either equity requirements or interest burden. This research is worth several hours of your time.
|
||||
|
||||
@@ -160,7 +198,12 @@ Courts are installed after the building envelope is weathertight. This is a hard
|
||||
|
||||
Glass panels, artificial turf, and court metalwork must not be exposed to construction dust, moisture, and site traffic. Projects that try to accelerate schedules by installing courts before the building is properly enclosed regularly end up with surface contamination, glass damage, and voided manufacturer warranties.
|
||||
|
||||
> **The most common construction mistake on padel hall projects:** rushing court installation sequencing under schedule pressure. The pressure to hit an opening date is real — but installing courts into an unenclosed building is one of the most reliable ways to add cost and delay, not reduce them. Hold the sequence.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">The most common construction mistake on padel hall projects</span>
|
||||
<p>Rushing court installation sequencing under schedule pressure. The pressure to hit an opening date is real — but installing courts into an unenclosed building is one of the most reliable ways to add cost and delay, not reduce them. Hold the sequence.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Allow two to four weeks for court installation per batch, depending on the manufacturer's crew capacity. Build this explicitly into your master program.
|
||||
|
||||
@@ -174,7 +217,12 @@ Decide early: which booking platform, which point-of-sale system, and whether yo
|
||||
|
||||
Access control systems must be coordinated with the electrical design. Adding them in the final stages of construction is possible but costs more.
|
||||
|
||||
> **The most common pre-opening mistake:** the booking system isn't fully configured, tested, and working on day one. A broken booking flow, failed test payments, or a QR code that leads to an error page on opening day kills your launch momentum in a way that's difficult to recover from. Test the system end-to-end — including real bookings, real payments, and real cancellations — two to four weeks before opening.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">The most common pre-opening mistake</span>
|
||||
<p>The booking system isn't fully configured, tested, and working on day one. A broken booking flow, failed test payments, or a QR code that leads to an error page on opening day kills your launch momentum in a way that's difficult to recover from. Test the system end-to-end — including real bookings, real payments, and real cancellations — two to four weeks before opening.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Step 16: Inspections and Certifications
|
||||
|
||||
@@ -248,18 +296,45 @@ Court bookings are your core revenue, but rarely your only opportunity:
|
||||
|
||||
Patterns emerge when you observe padel hall projects across a market over time.
|
||||
|
||||
**Projects that go over budget** almost always cut at the wrong place early — too little HVAC budget, no construction contingency, a cheap general contractor without adequate contractual protection. The savings on the way in become much larger costs on the way out.
|
||||
<div class="article-cards">
|
||||
<div class="article-card article-card--failure">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projects that go over budget</span>
|
||||
<p class="article-card__body">Almost always cut at the wrong place early — too little HVAC budget, no construction contingency, a cheap general contractor without adequate contractual protection. The savings on the way in become much larger costs on the way out.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--failure">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projects that slip their schedule</span>
|
||||
<p class="article-card__body">Consistently underestimate the regulatory process. Permits, noise assessments, and change-of-use applications take time that money cannot buy once you've started too late. Start conversations with authorities before you need the approvals.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--failure">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projects that open weakly</span>
|
||||
<p class="article-card__body">Started marketing too late and tested the booking system too late. An empty calendar on day one and a broken booking page create impressions that stick longer than the opening week.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projects that succeed long-term</span>
|
||||
<p class="article-card__body">Treat all three phases — planning, build, and opening — with equal rigor, and invest early and consistently in community and repeat customers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
**Projects that slip their schedule** consistently underestimate the regulatory process. Permits, noise assessments, and change-of-use applications take time that money cannot buy once you've started too late. Start conversations with authorities before you need the approvals, not when you need them.
|
||||
|
||||
**Projects that open weakly** started marketing too late and tested the booking system too late. An empty calendar on day one and a broken booking page create impressions that stick longer than the opening week.
|
||||
|
||||
**Projects that succeed long-term** treat all three phases — planning, build, and opening — with equal rigor, and invest early and consistently in community and repeat customers.
|
||||
Building a padel hall is complex, but it is a solved problem. The failures are nearly always the same failures. So are the successes.
|
||||
|
||||
---
|
||||
|
||||
## Find Builders and Suppliers Through Padelnomics
|
||||
## Find the Right Build Partners
|
||||
|
||||
Padelnomics maintains a directory of verified build partners for padel hall projects: architects with sports facility experience, court suppliers, HVAC specialists, and operational consultants.
|
||||
|
||||
If you're currently in Phase 1 or Phase 2 and looking for the right partners, the directory is the fastest place to start.
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
|
||||
<a href="/quote" class="btn">Request a Quote</a>
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,11 @@ cornerstone: C2
|
||||
|
||||
# How Much Does It Cost to Open a Padel Hall in Germany? Complete 2026 CAPEX Breakdown
|
||||
|
||||
Anyone who has started researching padel hall investment in Germany has encountered the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible.
|
||||
Anyone researching padel hall investment in Germany hits the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible.
|
||||
|
||||
But that range is not noise. It reflects specific, quantifiable decisions: whether you're fitting out an existing warehouse or building from scratch, whether you're in Munich or Leipzig, whether you want panorama glass courts or standard construction. Once you understand where the variance lives, the numbers become plannable.
|
||||
|
||||
This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 2025–2026. By the end, you should be able to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence.
|
||||
This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 2025–2026. By the end, you'll have everything you need to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@ This article gives you the complete picture: itemized CAPEX, city-by-city rent a
|
||||
|
||||
The single largest driver of CAPEX variance is construction. Converting a suitable existing warehouse — one that already has the necessary ceiling height (8–9 m clear) and adequate structural load — costs vastly less than a ground-up build or a complete gut-renovation. This line item alone accounts for €400,000 to €800,000 of the total budget.
|
||||
|
||||
Location adds another layer of variance. The same 2,000 sqm hall costs 40–60% more to rent in Munich than in Leipzig. That gap shows up not just in annual OPEX but in the lease deposit and the working capital reserve you need to fund the ramp-up — both of which are part of your initial CAPEX.
|
||||
Location adds another layer of variance. The same 2,000 sqm hall costs 40–60% more to rent in Munich than in Leipzig across comparable market tiers — at the extremes, the gap is considerably wider. That difference runs through every budget line: not just annual rent, but the lease deposit and working capital reserve needed at launch, both part of your initial CAPEX.
|
||||
|
||||
For a **six-court indoor facility** with solid but not extravagant fit-out, the realistic planning figure is **€1.2–1.5 million all-in**. Projects that come in below that typically either benefited from an exceptional real estate deal or — more often — undercounted one of the three most expensive items: construction, HVAC, and the operating reserve.
|
||||
|
||||
@@ -56,6 +56,8 @@ For a **six-court indoor facility** with solid but not extravagant fit-out, the
|
||||
|
||||
## Commercial Rent by German City
|
||||
|
||||
Construction and courts consume most of your initial budget. What determines long-term viability is what you pay every month: rent.
|
||||
|
||||
A six-court facility with changing rooms, a reception area, and a lounge requires **1,500–2,500 sqm** of floor space. Current industrial/warehouse lease rates across major German cities:
|
||||
|
||||
| City | Rent €/sqm/month | Typical monthly cost (2,000 sqm) |
|
||||
@@ -77,7 +79,7 @@ One structural note: German commercial landlords typically require lease terms o
|
||||
|
||||
## Court Hire Rates: What the Market Will Bear
|
||||
|
||||
Booking rates vary significantly by city and time slot. The following figures are drawn from platform data and direct market surveys:
|
||||
Revenue potential tracks location almost as closely as rent does. The following booking rates are drawn from platform data and direct market surveys:
|
||||
|
||||
| City | Off-Peak (€/hr) | Peak (€/hr) | Confidence |
|
||||
|---|---|---|---|
|
||||
@@ -113,6 +115,8 @@ Operating cost projections are where business plans most often diverge from real
|
||||
| Admin, accounting, legal | €20,000 | €22,000 | €24,000 |
|
||||
| **Total OPEX** | **€490,000** | **€530,000** | **€566,000** |
|
||||
|
||||
Note: the rent line reflects a well-positioned facility in a mid-tier city. For Munich or Berlin, adjust upward using the city rent table above — and recalibrate your revenue assumptions accordingly.
|
||||
|
||||
**Staffing** is the line that most first-time operators get wrong. Five FTEs is a genuine minimum for professional operations — reception, court management, a coach, administration. In Germany, employer social security contributions add roughly 20% on top of gross wages. €200k in Year 1 for a five-person team is lean, not generous.
|
||||
|
||||
**Energy** depends heavily on the building envelope. An older warehouse with poor insulation and an oversized, inefficient HVAC installation can run 30–50% higher than the figures shown here. Commissioning a quick energy audit before signing the lease is cheap insurance.
|
||||
@@ -167,13 +171,13 @@ On an €800k loan at 5% over 10 years, annual debt service is approximately €
|
||||
|
||||
## What Lenders Actually Look For
|
||||
|
||||
A padel hall is an unusual asset class for most bank credit officers. What moves a credit committee is not enthusiasm for the sport — it is the rigor of the financial documentation.
|
||||
A padel hall is an unfamiliar asset class for most bank credit officers. They have no mental model for court utilization rates or booking yield — and that is actually an opportunity. What moves a credit committee is not enthusiasm for the sport. It is the rigor of the financial documentation. Arrive with clean numbers and you stand out from the start.
|
||||
|
||||
**DSCR of 1.2–1.5x minimum.** Lenders want operating cash flow to cover debt service with a 20–50% buffer. The base case in this model clears that bar easily; your job is to show it holds under stress scenarios too.
|
||||
|
||||
**Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal to lenders — it translates future revenue into something closer to contracted income.
|
||||
**Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal — it converts uncertain future revenue into something closer to contracted income on the credit committee's worksheet.
|
||||
|
||||
**Monthly cashflow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness.
|
||||
**Monthly cash flow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness.
|
||||
|
||||
**Sensitivity analysis.** Show three scenarios: base case (45–60% utilization), downside (35%), and stress (25%). If your project only works at optimistic assumptions, that is important information — for you, not just for the bank.
|
||||
|
||||
@@ -183,8 +187,14 @@ A dedicated article on structuring a padel hall business plan and navigating Ger
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.2–1.5M as the honest planning figure for a solid six-court operation. The economics, modelled carefully, are genuinely attractive — payback in 3–5 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow.
|
||||
Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.2–1.5M as the honest planning figure for a solid six-court operation. The economics, done right, are genuinely attractive — payback in 3–5 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow.
|
||||
|
||||
The investors who succeed in this space are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
|
||||
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
|
||||
|
||||
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The model above is the starting point. Your hall deserves a projection built around your actual numbers.
|
||||
**Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
|
||||
<a href="/quote" class="btn">Request a Quote</a>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,8 @@ Every state has a development bank: Investitionsbank Schleswig-Holstein, Thürin
|
||||
|
||||
## Personal Guarantee Reality: Don't Avoid This Conversation
|
||||
|
||||
Once the debt structure is in place, there is one more item that belongs in every financing conversation — and that is too often skipped until the term sheet arrives.
|
||||
|
||||
German banks financing a padel hall through a standalone project company will almost always require **persönliche Bürgschaft** (personal guarantee) from the founders. This means your personal assets — home, savings, existing investments — are at risk if the business fails.
|
||||
|
||||
Three ways to limit this exposure:
|
||||
@@ -177,3 +179,9 @@ Your most powerful tool in every bank meeting: a complete financial model demons
|
||||
[scenario:padel-halle-6-courts:full]
|
||||
|
||||
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
|
||||
<a href="/quote" class="btn">Request a Quote</a>
|
||||
</div>
|
||||
|
||||
@@ -21,20 +21,20 @@ This article covers the 14 risks that don't get enough airtime in investor discu
|
||||
|
||||
| # | Risk | Category | Severity |
|
||||
|---|------|----------|----------|
|
||||
| 1 | Trend / fad risk | Strategic | High |
|
||||
| 2 | Construction cost overruns | Construction & Development | High |
|
||||
| 3 | Construction delays | Construction & Development | High |
|
||||
| 4 | Landlord risk: sale, insolvency, non-renewal | Property & Lease | High |
|
||||
| 5 | New competitor in your catchment | Competition | Medium–High |
|
||||
| 6 | Key-person dependency | Operations | Medium |
|
||||
| 7 | Staff retention and wage pressure | Operations | Medium |
|
||||
| 8 | Court surface and maintenance cycles | Operations | Medium |
|
||||
| 9 | Energy price volatility | Financial | Medium |
|
||||
| 10 | Interest rate risk | Financial | Medium |
|
||||
| 11 | Personal guarantee exposure | Financial | High |
|
||||
| 12 | Customer concentration | Financial | Medium |
|
||||
| 13 | Noise complaints and regulatory restrictions | Regulatory & Legal | Medium |
|
||||
| 14 | Booking platform dependency | Regulatory & Legal | Low–Medium |
|
||||
| 1 | Trend / fad risk | Strategic | <span class="severity severity--high">High</span> |
|
||||
| 2 | Construction cost overruns | Construction & Development | <span class="severity severity--high">High</span> |
|
||||
| 3 | Construction delays | Construction & Development | <span class="severity severity--high">High</span> |
|
||||
| 4 | Landlord risk: sale, insolvency, non-renewal | Property & Lease | <span class="severity severity--high">High</span> |
|
||||
| 5 | New competitor in your catchment | Competition | <span class="severity severity--medium-high">Medium–High</span> |
|
||||
| 6 | Key-person dependency | Operations | <span class="severity severity--medium">Medium</span> |
|
||||
| 7 | Staff retention and wage pressure | Operations | <span class="severity severity--medium">Medium</span> |
|
||||
| 8 | Court surface and maintenance cycles | Operations | <span class="severity severity--medium">Medium</span> |
|
||||
| 9 | Energy price volatility | Financial | <span class="severity severity--medium">Medium</span> |
|
||||
| 10 | Interest rate risk | Financial | <span class="severity severity--medium">Medium</span> |
|
||||
| 11 | Personal guarantee exposure | Financial | <span class="severity severity--high">High</span> |
|
||||
| 12 | Customer concentration | Financial | <span class="severity severity--medium">Medium</span> |
|
||||
| 13 | Noise complaints and regulatory restrictions | Regulatory & Legal | <span class="severity severity--medium">Medium</span> |
|
||||
| 14 | Booking platform dependency | Regulatory & Legal | <span class="severity severity--low-medium">Low–Medium</span> |
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +50,7 @@ Squash followed a strikingly similar pattern in the 1980s: grassroots boom, infr
|
||||
|
||||
The counterargument has real merit: padel requires permanent, fixed courts. That infrastructure creates genuine stickiness that squash never had — players build habits, drive to a venue, become regulars. Padel is also demonstrably more accessible and social than squash, which supports long-term participation. German player numbers show no plateau effect yet.
|
||||
|
||||
Even so: if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan.
|
||||
Even so — if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +91,7 @@ When a new competitor opens ten minutes away in year three, you feel it in utili
|
||||
|
||||
Padel has no real moat. No patents, no network effects, no meaningful switching costs. What you have is location, the community you've built, and service quality — genuine advantages, but ones that require continuous investment to maintain.
|
||||
|
||||
**The right move is to model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Having thought through the competitive response in advance means you won't be improvising when it happens.
|
||||
**Model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Thinking through the competitive response in advance means you won't be improvising when it happens.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +111,7 @@ Good facility managers, coaches who combine technical skill with genuine hospita
|
||||
|
||||
Courts need replacing. Artificial turf has a lifespan of five to eight years. Glass panels and framework require regular inspection and periodic replacement. If this isn't in your long-term financial model, you're looking at a significant unplanned capital call in year six or seven. Budget a per-court annual refurbishment reserve — and set it conservatively above zero.
|
||||
|
||||
**A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before you commit to running it in-house.
|
||||
**A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before committing to running it in-house.
|
||||
|
||||
---
|
||||
|
||||
@@ -137,9 +137,12 @@ Your costs will increase three to five percent per year. Whether you can pass th
|
||||
|
||||
## The Risk No One Talks About: Personal Guarantees
|
||||
|
||||
**This section gets skipped in almost every padel hall investment conversation. That's a serious mistake.**
|
||||
|
||||
Banks financing a single-asset leisure facility without corporate backing will almost universally require personal guarantees from the principal shareholders. Not as an unusual request — as standard terms for this type of deal.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">This section gets skipped in almost every padel hall investment conversation. That's a serious mistake.</span>
|
||||
<p>Banks financing a single-asset leisure facility without corporate backing will almost universally require personal guarantees from the principal shareholders. Not as an unusual request — as standard terms for this type of deal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Here is what that means in practice:
|
||||
|
||||
@@ -180,18 +183,47 @@ Building a parallel booking capability — even a simple direct booking option
|
||||
|
||||
The investors who succeed long-term in padel aren't the ones who found a risk-free opportunity. There isn't one. They're the ones who went in with their eyes open.
|
||||
|
||||
**They modeled the bad scenarios before assuming the good ones.** A business plan that shows only the base case isn't a planning tool — it's wishful thinking. Explicit downside modeling — 40% utilization, six-month delay, new competitor in year three — is the baseline, not an optional exercise.
|
||||
|
||||
**They built structural buffers into the plan.** Liquid reserves covering at least six months of fixed costs. Construction contingency treated as a budget line, not a hedge. These aren't comfort margins; they're operational requirements.
|
||||
|
||||
**They got the contractual foundations right from the start.** Lease terms. Financing conditions. Guarantee scope. The cost of good legal and financial advice at the planning stage is trivial relative to the downside exposure it addresses.
|
||||
|
||||
**They planned for competition.** Not by hoping it wouldn't come, but by building a product — community, quality, service — that gives existing customers a reason to stay when someone cheaper opens nearby.
|
||||
<div class="article-cards">
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Model the bad scenarios first</span>
|
||||
<p class="article-card__body">A business plan showing only the base case isn't a planning tool — it's wishful thinking. Explicit downside modeling — 40% utilization, six-month delay, new competitor in year three — is the baseline, not an optional exercise.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Build structural buffers in</span>
|
||||
<p class="article-card__body">Liquid reserves covering at least six months of fixed costs. Construction contingency treated as a budget line, not a hedge. These aren't comfort margins; they're operational requirements.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Get the contractual foundations right</span>
|
||||
<p class="article-card__body">Lease terms. Financing conditions. Guarantee scope. The cost of good legal and financial advice at the planning stage is trivial relative to the downside exposure it addresses.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Plan for competition</span>
|
||||
<p class="article-card__body">Not by hoping it won't come, but by building a product — community, quality, service — that gives existing customers a reason to stay when someone cheaper opens nearby.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Model the Downside with Padelnomics
|
||||
|
||||
The Padelnomics investment planner includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
|
||||
The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
|
||||
|
||||
Good decisions need an honest model — not just the best-case assumptions.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
|
||||
<a href="/quote" class="btn">Request a Quote</a>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@ Key checks before committing to a site:
|
||||
|
||||
## The Site Scoring Framework: From 8 Criteria to a Decision
|
||||
|
||||
Anyone evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 1–5 and multiplied by a weighting factor.
|
||||
Any investor evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 1–5 and multiplied by a weighting factor.
|
||||
|
||||
A suggested weighting:
|
||||
|
||||
@@ -148,17 +148,35 @@ The matrix also reveals where trade-offs are being made explicitly, which makes
|
||||
|
||||
The 8 criteria above evaluate specific sites. But before shortlisting sites, it is worth stepping back to read the stage of the overall market — because the right operational strategy differs fundamentally depending on where a city sits in its padel development cycle.
|
||||
|
||||
**Established markets**: Booking platforms show consistent peak-hour sell-out across most venues. Waiting lists are common. Demand is validated beyond doubt. The challenge here is elevated rent, elevated build costs, and entrenched operators who have already captured community loyalty. New entrants need a genuine differentiation angle — a superior facility specification, a better location within the city, or an F&B and coaching product that existing venues don't offer. Entry costs are high; returns, if execution is strong, are also high. Munich is the canonical German example.
|
||||
|
||||
**Growth markets**: Demand is clearly building — booking availability tightens at weekends, new facilities are announced regularly, and the sport is gaining local media visibility. Supply hasn't caught up, so identifiable gaps still exist in specific districts or the surrounding hinterland. The risk profile is lower than in emerging markets, but the window for securing good real estate at reasonable rent is narrowing. The premium for moving decisively goes to those who arrive before the obvious sites are taken.
|
||||
|
||||
**Emerging markets**: Limited current supply, a small but growing player base, and padel not yet mainstream enough to generate organic walk-in demand. Entry costs — rent especially — are lower. The constraint is that demand must be actively created rather than captured. Operators who succeed here invest in community: beginner programmes, local leagues, school partnerships, conversions from tennis clubs. The time to first profitability is longer, but the competitive position built in the first two years is often decisive for the long term.
|
||||
<div class="article-cards">
|
||||
<div class="article-card article-card--established">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Established markets</span>
|
||||
<p class="article-card__body">Booking platforms show consistent peak-hour sell-out. Demand is validated. The challenge: elevated rent, high build costs, entrenched operators. New entrants need a genuine differentiation angle — superior spec, better location, or F&B and coaching that existing venues don't offer. Entry costs are high; returns, if execution is strong, are also high. Munich is the canonical German example.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--growth">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Growth markets</span>
|
||||
<p class="article-card__body">Demand is clearly building — booking availability tightens at weekends, new facilities are announced regularly. Supply hasn't caught up; identifiable gaps still exist. The risk profile is lower, but the window for securing good real estate at reasonable rent is narrowing. The premium goes to those who arrive before the obvious sites are taken.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--emerging">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Emerging markets</span>
|
||||
<p class="article-card__body">Limited supply, a small but growing player base, padel not yet mainstream. Entry costs — rent especially — are lower. The constraint: demand must be actively created rather than captured. Operators who succeed invest in community: beginner programmes, local leagues, school partnerships. Time to profitability is longer, but the competitive position built in the first two years is often decisive.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Before committing to a site search in any city, calibrate where it sits on this spectrum. The 8-criteria framework then tells you whether a specific site works; market maturity tells you what kind of operator and strategy is required to make it work at all.
|
||||
|
||||
Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites.
|
||||
|
||||
[→ View market data by country](/markets/germany)
|
||||
[→ View market data by country](/en/markets/germany)
|
||||
|
||||
---
|
||||
|
||||
@@ -166,4 +184,10 @@ Padelnomics tracks venue density, booking platform utilisation, and demographic
|
||||
|
||||
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
|
||||
|
||||
[→ Run a location analysis]
|
||||
[→ Run a location analysis](/en/planner)
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
|
||||
<a href="/quote" class="btn">Request a Quote</a>
|
||||
</div>
|
||||
|
||||
@@ -17,15 +17,48 @@ Dieser Leitfaden zeigt Ihnen alle 5 Phasen und 23 Schritte, die zwischen Ihrer e
|
||||
|
||||
## Die 5 Phasen im Überblick
|
||||
|
||||
```
|
||||
Phase 1 Phase 2 Phase 3 Phase 4 Phase 5
|
||||
Machbarkeit → Planung & → Bau / → Voreröff- → Betrieb &
|
||||
& Konzept Design Umbau nung Optimierung
|
||||
|
||||
Monat 1–3 Monat 3–6 Monat 6–12 Monat 10–13 laufend
|
||||
|
||||
Schritte 1–5 Schritte 6–11 Schritte 12–16 Schritte 17–20 Schritte 21–23
|
||||
```
|
||||
<div class="article-timeline">
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">1</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Machbarkeit & Konzept</div>
|
||||
<div class="article-timeline__subtitle">Marktanalyse, Konzept, Standortsuche</div>
|
||||
<div class="article-timeline__meta">Monat 1–3 · Schritte 1–5</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">2</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Planung & Design</div>
|
||||
<div class="article-timeline__subtitle">Architekt, Genehmigungen, Finanzierung</div>
|
||||
<div class="article-timeline__meta">Monat 3–6 · Schritte 6–11</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">3</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Bau / Umbau</div>
|
||||
<div class="article-timeline__subtitle">Rohbau, Courts, IT-Systeme</div>
|
||||
<div class="article-timeline__meta">Monat 6–12 · Schritte 12–16</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">4</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Voreröffnung</div>
|
||||
<div class="article-timeline__subtitle">Personal, Marketing, Soft Launch</div>
|
||||
<div class="article-timeline__meta">Monat 10–13 · Schritte 17–20</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-timeline__phase">
|
||||
<div class="article-timeline__num">5</div>
|
||||
<div class="article-timeline__card">
|
||||
<div class="article-timeline__title">Betrieb & Optimierung</div>
|
||||
<div class="article-timeline__subtitle">Einnahmen, Community, Optimierung</div>
|
||||
<div class="article-timeline__meta">laufend · Schritte 21–23</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -104,7 +137,12 @@ Was in dieser Phase entsteht:
|
||||
- MEP-Planung (Haustechnik): Heizung, Lüftung, Klimaanlage, Elektro, Sanitär — das sind bei Sporthallen oft die kostenintensivsten Gewerke
|
||||
- Brandschutzkonzept
|
||||
|
||||
**Häufiger Fehler in dieser Phase:** Die Haustechnik wird unterschätzt. Eine große Innenhalle braucht präzise Temperatur- und Feuchtigkeitskontrolle — für die Spielqualität, für die Langlebigkeit des Belags und für das Wohlbefinden der Spieler. Eine schlechte HVAC-Anlage ist eine Dauerbaustelle.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">Häufiger Fehler in dieser Phase</span>
|
||||
<p>Die Haustechnik wird unterschätzt. Eine große Innenhalle braucht präzise Temperatur- und Feuchtigkeitskontrolle — für die Spielqualität, für die Langlebigkeit des Belags und für das Wohlbefinden der Spieler. Eine schlechte HVAC-Anlage ist eine Dauerbaustelle.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Schritt 8: Courtlieferant auswählen
|
||||
|
||||
@@ -122,7 +160,7 @@ Mit dem detaillierten Businessplan gehen Sie zu Banken und ggf. Fördermittelgeb
|
||||
- 50–70 Prozent Fremdkapital (Bankdarlehen)
|
||||
- 30–50 Prozent Eigenkapital (eigene Mittel, stille Beteiligungen, Gesellschafterdarlehen)
|
||||
|
||||
Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönliches Track Record, und — fast immer — eine persönliche Bürgschaft. (Mehr dazu im separaten Artikel zu Investitionsrisiken.)
|
||||
Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönlicher Track Record, und — fast immer — eine persönliche Bürgschaft. Der separate Artikel zu Investitionsrisiken behandelt das Thema Bürgschaftsexposition ausführlich.
|
||||
|
||||
Klären Sie Förderprogramme: KfW-Mittel, Landesförderbanken und kommunale Sportförderprogramme können den Eigenkapitalbedarf oder die Zinsbelastung reduzieren. Diese Recherche lohnt sich.
|
||||
|
||||
@@ -155,7 +193,12 @@ Verhandeln Sie Festpreise, wo möglich. Lesen Sie die Risikoverteilung in den Ve
|
||||
|
||||
Courts werden nach Fertigstellung der Gebäudehülle montiert — das ist eine harte Reihenfolge, keine Empfehlung. Glaselemente dürfen nicht Feuchtigkeit, Staub und Baustellenverkehr ausgesetzt werden, bevor das Gebäude dicht ist.
|
||||
|
||||
**Ein häufiger und vermeidbarer Fehler:** Projekte, die unter Zeitdruck stehen, versuchen, Court-Montage vorzuziehen. Das Ergebnis sind beschädigte Oberflächen, Glasschäden, Verschmutzungen im Belag und Gewährleistungsprobleme mit dem Hersteller. Halten Sie die Reihenfolge ein — konsequent.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">Ein häufiger und vermeidbarer Fehler</span>
|
||||
<p>Projekte unter Zeitdruck versuchen, die Court-Montage vorzuziehen. Das Ergebnis sind beschädigte Oberflächen, Glasschäden, Verschmutzungen im Belag und Gewährleistungsprobleme mit dem Hersteller. Halten Sie die Reihenfolge ein — konsequent.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Die Montage von Courts dauert je nach Hersteller und Parallelkapazität zwei bis vier Wochen pro Charge. Planen Sie das in den Gesamtablauf ein.
|
||||
|
||||
@@ -169,7 +212,12 @@ Frühzeitig entscheiden: Playtomic, Matchi, ein anderes System oder eine Hybridl
|
||||
|
||||
Zugangskontrolle (falls gewünscht) muss mit der Elektroplanung koordiniert werden. Wer das in der letzten Bauphase ergänzen möchte, zahlt dafür.
|
||||
|
||||
**Der häufigste Fehler kurz vor der Eröffnung:** Am Tag der Eröffnung ist das Buchungssystem noch nicht richtig konfiguriert, Testzahlungen schlagen fehl, der QR-Code am Eingang führt auf eine Fehlerseite. Der Eröffnungsbuzz ist ein einmaliges Gut. Testen Sie das System zwei bis vier Wochen vorher vollständig — inklusive echter Buchungen, echter Zahlungen und echter Stornierungen.
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">Der häufigste Fehler kurz vor der Eröffnung</span>
|
||||
<p>Am Tag der Eröffnung ist das Buchungssystem noch nicht richtig konfiguriert, Testzahlungen schlagen fehl, der QR-Code am Eingang führt auf eine Fehlerseite. Der Eröffnungsbuzz ist ein einmaliges Gut. Testen Sie das System zwei bis vier Wochen vorher vollständig — inklusive echter Buchungen, echter Zahlungen und echter Stornierungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Schritt 16: Abnahmen und Zertifizierungen
|
||||
|
||||
@@ -243,18 +291,45 @@ Die Court-Buchung ist Ihr Kernangebot — aber nicht die einzige Einnahmequelle:
|
||||
|
||||
Wer Dutzende Padelhallenprojekte in Europa beobachtet, sieht Muster auf beiden Seiten:
|
||||
|
||||
**Die Projekte, die über Budget laufen**, haben fast immer früh an der falschen Stelle gespart — zu wenig Haustechnikbudget, kein Baukostenpuffer, zu günstiger Generalunternehmer ohne ausreichende Vertragsabsicherung.
|
||||
<div class="article-cards">
|
||||
<div class="article-card article-card--failure">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projekte, die über Budget laufen</span>
|
||||
<p class="article-card__body">Haben fast immer früh an der falschen Stelle gespart — zu wenig Haustechnikbudget, kein Baukostenpuffer, zu günstiger Generalunternehmer ohne ausreichende Vertragsabsicherung.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--failure">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projekte, die terminlich entgleisen</span>
|
||||
<p class="article-card__body">Haben die behördlichen Prozesse unterschätzt. Genehmigungen, Lärmschutzgutachten, Nutzungsänderungen brauchen Zeit — und diese Zeit lässt sich nicht kaufen, sobald man zu spät damit anfängt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--failure">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projekte, die schwach starten</span>
|
||||
<p class="article-card__body">Haben das Marketing zu spät begonnen und das Buchungssystem zu spät getestet. Ein leerer Kalender am Eröffnungstag und eine kaputte Buchungsseite erzeugen Eindrücke, die sich festsetzen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Projekte, die langfristig erfolgreich sind</span>
|
||||
<p class="article-card__body">Behandeln alle drei Phasen — Planung, Bau, Eröffnung — mit derselben Sorgfalt und investieren früh in Community und Stammkundschaft.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
**Die Projekte, die terminlich entgleisen**, haben die behördlichen Prozesse unterschätzt. Genehmigungen, Lärmschutzgutachten, Nutzungsänderungen brauchen Zeit — und diese Zeit lässt sich nicht kaufen, sobald man zu spät damit anfängt.
|
||||
|
||||
**Die Projekte, die schwach starten**, haben das Marketing zu spät begonnen und das Buchungssystem zu spät getestet. Ein leerer Kalender am Eröffnungstag und eine kaputte Buchungsseite erzeugen Eindrücke, die sich festsetzen.
|
||||
|
||||
**Die Projekte, die langfristig erfolgreich sind**, haben alle drei Phasen — Planung, Bau, Eröffnung — mit derselben Sorgfalt behandelt und früh in Community und Stammkundschaft investiert.
|
||||
Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehler, die Projekte scheitern lassen, sind fast immer dieselben. Genauso wie die Entscheidungen, die sie gelingen lassen.
|
||||
|
||||
---
|
||||
|
||||
## Planer und Lieferanten finden
|
||||
## Die richtigen Baupartner finden
|
||||
|
||||
Padelnomics führt ein Verzeichnis verifizierter Baupartner für Padelhallen im DACH-Raum: Architekten mit Sportanlagenerfahrung, Court-Lieferanten, Haustechnikspezialisten und Betriebsberater.
|
||||
|
||||
Wenn Sie gerade in Phase 1 oder Phase 2 sind und die richtigen Partner suchen, ist das Verzeichnis der schnellste Einstieg.
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
|
||||
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +159,8 @@ Der Kapitaldienstdeckungsgrad (DSCR) auf den Bankkredit (€700k, 5 %, 10 Jahre
|
||||
|
||||
## Das persönliche Risiko: Bürgschaften offen ansprechen
|
||||
|
||||
Steht die Fremdkapitalstruktur, bleibt eine Frage, die in fast jedem Finanzierungsgespräch zu spät gestellt wird — und die zu oft erst auf dem Konditionenblatt der Bank auftaucht.
|
||||
|
||||
Banken werden für eine Padelhalle, die eine eigenständige Projektgesellschaft ist, fast immer eine **persönliche Bürgschaft** des Gründers fordern. Das bedeutet: Ihre privaten Vermögenswerte — Eigenheim, Ersparnisse, Beteiligungen — haften im Zweifelsfall.
|
||||
|
||||
Es gibt drei Wege, dieses Risiko zu begrenzen:
|
||||
@@ -197,3 +199,9 @@ Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell
|
||||
[scenario:padel-halle-6-courts:full]
|
||||
|
||||
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
|
||||
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ cornerstone: C2
|
||||
|
||||
# Padel Halle Kosten 2026: Die komplette CAPEX-Aufstellung
|
||||
|
||||
Wer ernsthaft über eine Padelhalle nachdenkt, bekommt auf die Frage nach den Kosten zunächst eine frustrierende Antwort: "Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden.
|
||||
Wer eine Padelhalle plant, bekommt auf die Kostenfrage zunächst eine frustrierende Antwort: „Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden.
|
||||
|
||||
Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubstanz über Platztechnik und Ausstattung bis hin zu Betriebskosten, Standortmieten und einer belastbaren 3-Jahres-Ergebnisprognose. Alle Zahlen basieren auf realen deutschen Marktdaten aus 2025/2026. Das Ziel: Sie sollen nach der Lektüre in der Lage sein, eine erste realistische Wirtschaftlichkeitsrechnung für Ihre konkrete Situation aufzustellen — und wissen, welche Fragen Sie Ihrer Bank stellen müssen.
|
||||
|
||||
@@ -19,7 +19,7 @@ Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubs
|
||||
|
||||
Warum liegen €930.000 und €1,9 Millionen so weit auseinander? Der größte Einzeltreiber ist der bauliche Aufwand. Wer eine bestehende Gewerbehalle — etwa einen ehemaligen Produktions- oder Logistikbau — kostengünstig anmieten und mit minimalem Umbau bespielen kann, landet am unteren Ende der Spanne. Wer dagegen auf grüner Wiese baut oder ein Gebäude von Grund auf saniert, zahlt entsprechend mehr.
|
||||
|
||||
Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in der Miete 40–60 % mehr als in Leipzig oder Kassel. Das drückt sich nicht nur in der laufenden OPEX aus, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX.
|
||||
Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in vergleichbaren Marktsegmenten 40–60 % mehr als in Leipzig oder Kassel — an den Extremen fällt der Abstand erheblich größer aus. Das schlägt sich nicht nur in der laufenden OPEX nieder, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX.
|
||||
|
||||
Realistischer Planungsansatz für eine **6-Court-Innenhalle** mit solider Ausstattung: **€1,2–1,5 Millionen Gesamtinvestition**. Wer mit deutlich weniger kalkuliert, unterschätzt in der Regel einen der drei teuersten Posten: Bau/Umbau, Lüftungstechnik oder den Kapitalpuffer für den Anlauf.
|
||||
|
||||
@@ -56,6 +56,8 @@ Die folgende Tabelle zeigt die typischen Bandbreiten für eine sechsstellige Inn
|
||||
|
||||
## Hallenmiete in Deutschland: Was Sie nach Standort zahlen
|
||||
|
||||
Bau und Courts binden den größten Teil des Startkapitals. Was über die langfristige Wirtschaftlichkeit entscheidet, zahlen Sie monatlich: die Miete.
|
||||
|
||||
Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) eine Fläche von **1.500 bis 2.500 qm**. Auf Basis aktueller Gewerberaummieten für Industrie- und Hallenflächen in deutschen Städten ergibt sich folgende Einschätzung:
|
||||
|
||||
| Stadt | Miete €/qm/Monat | Typische Monatsmiete (2.000 qm) |
|
||||
@@ -69,15 +71,15 @@ Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) ein
|
||||
| Stuttgart | €7–10 | €14.000–€20.000 |
|
||||
| Leipzig | €4–7 | €8.000–€14.000 |
|
||||
|
||||
In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt.
|
||||
In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt. Für München oder Berlin kalkulieren Sie mit den Werten aus der Stadtübersicht oben — und passen Sie die Erlösannahme entsprechend an.
|
||||
|
||||
Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 5–10 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Banken bewerten einen langen Mietvertrag mit festen Konditionen positiv.
|
||||
Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 5–10 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Ein langfristiger Mietvertrag mit indexierter Staffelung ist für die Bank ein echtes Positivsignal — er macht aus unsicheren künftigen Einnahmen etwas, das im Kreditbescheid wie planbarer Cashflow aussieht.
|
||||
|
||||
---
|
||||
|
||||
## Platzbuchungspreise: Was der Markt trägt
|
||||
|
||||
Die Mietpreise sind das Fundament Ihrer Ertragsrechnung. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen:
|
||||
Das Ertragspotenzial folgt der Standortlogik ähnlich eng wie die Mietkosten. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen:
|
||||
|
||||
| Stadt | Nebenzeiten (€/Std.) | Hauptzeiten (€/Std.) | Datenbasis |
|
||||
|---|---|---|---|
|
||||
@@ -167,7 +169,7 @@ Bei einem Darlehen von €800.000 (z. B. KfW oder Hausbank), 5 % Zinsen und 10 J
|
||||
|
||||
## Was Banken wirklich wollen
|
||||
|
||||
Eine Padelhalle ist für die meisten Bankberater ein ungewohntes Investitionsobjekt. Was zählt, ist nicht die Begeisterung für Padel — sondern die Qualität Ihrer Zahlengrundlage.
|
||||
Eine Padelhalle ist für die meisten Bankberater unbekanntes Terrain. Auslastungsquoten und Erlöse pro Court sind keine Größen, mit denen Kreditausschüsse täglich arbeiten — das ist Ihr Vorteil. Wer mit sauberen Zahlen und strukturierter Dokumentation ins Gespräch geht, fällt sofort positiv auf. Was den Kreditausschuss bewegt, ist nicht die Begeisterung für den Sport, sondern die Belastbarkeit der Unterlagen.
|
||||
|
||||
**Debt Service Coverage Ratio (DSCR) 1,2–1,5x:** Die Bank will sehen, dass Ihr operativer Cashflow den Schuldendienst mit einem Puffer von 20–50 % abdeckt. Mit einem EBITDA von €310.000 im ersten Jahr und einem Schuldendienst von €102.000 liegt der DSCR bei 3,0 — auf dem Papier sehr solide. Aber: Banken werden nachfragen, wie empfindlich dieses Ergebnis auf niedrigere Auslastung reagiert.
|
||||
|
||||
@@ -185,6 +187,12 @@ Wie Sie einen vollständigen Businessplan strukturieren und welche Unterlagen Ba
|
||||
|
||||
Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,9 Millionen, realistischer Mittelpunkt €1,2–1,5 Millionen. Wer diese Zahlen kennt und versteht, wo die Hebel sitzen, kann daraus ein belastbares Investitionsmodell bauen. Wer mit Schätzungen aus zweiter Hand ins Bankgespräch geht, verliert Zeit und Glaubwürdigkeit.
|
||||
|
||||
Die Wirtschaftlichkeit stimmt: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 3–5 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
|
||||
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 3–5 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
|
||||
|
||||
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Das Modell oben ist der Einstieg. Ihre Halle verdient eine maßgeschneiderte Kalkulation.
|
||||
**Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
|
||||
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||
</div>
|
||||
|
||||
@@ -21,20 +21,20 @@ Dieser Artikel zeigt Ihnen die 14 Risiken, über die in Investorenrunden zu weni
|
||||
|
||||
| # | Risiko | Kategorie | Schwere |
|
||||
|---|--------|-----------|---------|
|
||||
| 1 | Trend-/Modeerscheinung | Strategisch | Hoch |
|
||||
| 2 | Baukostenüberschreitungen | Bau & Entwicklung | Hoch |
|
||||
| 3 | Verzögerungen während des Baus | Bau & Entwicklung | Hoch |
|
||||
| 4 | Vermieterproblem: Verkauf, Insolvenz, keine Verlängerung | Immobilie & Mietvertrag | Hoch |
|
||||
| 5 | Neue Konkurrenz im Einzugsgebiet | Wettbewerb | Mittel–Hoch |
|
||||
| 6 | Schlüsselpersonen-Abhängigkeit | Betrieb | Mittel |
|
||||
| 7 | Fachkräftemangel und Lohndruck | Betrieb | Mittel |
|
||||
| 8 | Instandhaltungszyklen für Belag, Glas, Kunstrasen | Betrieb | Mittel |
|
||||
| 9 | Energiepreisvolatilität | Finanzen | Mittel |
|
||||
| 10 | Zinsänderungsrisiko | Finanzen | Mittel |
|
||||
| 11 | Persönliche Bürgschaft | Finanzen | Hoch |
|
||||
| 12 | Kundenkonzentration | Finanzen | Mittel |
|
||||
| 13 | Lärmbeschwerden und behördliche Auflagen | Regulatorisch & Rechtlich | Mittel |
|
||||
| 14 | Buchungsplattform-Abhängigkeit | Regulatorisch & Rechtlich | Niedrig–Mittel |
|
||||
| 1 | Trend-/Modeerscheinung | Strategisch | <span class="severity severity--high">Hoch</span> |
|
||||
| 2 | Baukostenüberschreitungen | Bau & Entwicklung | <span class="severity severity--high">Hoch</span> |
|
||||
| 3 | Verzögerungen während des Baus | Bau & Entwicklung | <span class="severity severity--high">Hoch</span> |
|
||||
| 4 | Vermieterproblem: Verkauf, Insolvenz, keine Verlängerung | Immobilie & Mietvertrag | <span class="severity severity--high">Hoch</span> |
|
||||
| 5 | Neue Konkurrenz im Einzugsgebiet | Wettbewerb | <span class="severity severity--medium-high">Mittel–Hoch</span> |
|
||||
| 6 | Schlüsselpersonen-Abhängigkeit | Betrieb | <span class="severity severity--medium">Mittel</span> |
|
||||
| 7 | Fachkräftemangel und Lohndruck | Betrieb | <span class="severity severity--medium">Mittel</span> |
|
||||
| 8 | Instandhaltungszyklen für Belag, Glas, Kunstrasen | Betrieb | <span class="severity severity--medium">Mittel</span> |
|
||||
| 9 | Energiepreisvolatilität | Finanzen | <span class="severity severity--medium">Mittel</span> |
|
||||
| 10 | Zinsänderungsrisiko | Finanzen | <span class="severity severity--medium">Mittel</span> |
|
||||
| 11 | Persönliche Bürgschaft | Finanzen | <span class="severity severity--high">Hoch</span> |
|
||||
| 12 | Kundenkonzentration | Finanzen | <span class="severity severity--medium">Mittel</span> |
|
||||
| 13 | Lärmbeschwerden und behördliche Auflagen | Regulatorisch & Rechtlich | <span class="severity severity--medium">Mittel</span> |
|
||||
| 14 | Buchungsplattform-Abhängigkeit | Regulatorisch & Rechtlich | <span class="severity severity--low-medium">Niedrig–Mittel</span> |
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +89,7 @@ Wenn in Jahr drei ein neuer Wettbewerber 10 Fahrminuten entfernt aufmacht, ist I
|
||||
|
||||
Einen echten Burggraben gibt es im Padel-Geschäft kaum. Keine Patente, keine Netzwerkeffekte, keine Wechselkosten. Was bleibt, ist: Standort, Gemeinschaft, Servicequalität und die Beziehung zu Stammkunden. Das sind reale Vorteile — aber sie müssen aktiv aufgebaut und gepflegt werden.
|
||||
|
||||
**Was Sie jetzt schon tun können:** Modellieren Sie im Businessplan explizit das Szenario "neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität?
|
||||
**Rechnen Sie das durch.** Modellieren Sie im Businessplan explizit das Szenario „neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität?
|
||||
|
||||
---
|
||||
|
||||
@@ -133,9 +133,14 @@ Ihre Kosten steigen jedes Jahr um drei bis fünf Prozent. Können Sie diese Stei
|
||||
|
||||
## Sonderbox: Persönliche Bürgschaft — das unterschätzte Risiko Nr. 1
|
||||
|
||||
**Dieses Thema wird in fast jedem Gespräch über Padelhallen-Investitionen ausgelassen. Das ist ein Fehler.**
|
||||
<div class="article-callout article-callout--warning">
|
||||
<div class="article-callout__body">
|
||||
<span class="article-callout__title">Dieses Thema wird in fast jedem Gespräch über Padelhallen-Investitionen ausgelassen. Das ist ein Fehler.</span>
|
||||
<p>Banken, die einer Einzelanlage ohne Konzernrückhalt Kapital bereitstellen, verlangen in der Praxis fast immer eine persönliche Bürgschaft des oder der Hauptgesellschafter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Banken, die einer Einzelanlage ohne Konzernrückhalt Kapital bereitstellen, verlangen in der Praxis fast immer eine persönliche Bürgschaft des oder der Hauptgesellschafter. Das bedeutet: Wenn das Unternehmen in Zahlungsschwierigkeiten gerät, haftet nicht die GmbH allein — Sie haften persönlich. Mit dem Eigenheim. Mit dem Ersparten. Mit dem Depot.
|
||||
Das bedeutet: Wenn das Unternehmen in Zahlungsschwierigkeiten gerät, haftet nicht die GmbH allein — Sie haften persönlich. Mit dem Eigenheim. Mit dem Ersparten. Mit dem Depot.
|
||||
|
||||
Die Struktur sieht dann typischerweise so aus:
|
||||
|
||||
@@ -176,18 +181,47 @@ Mittel- bis langfristig sollten Sie eine eigene Buchungsfähigkeit aufbauen —
|
||||
|
||||
Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfolgreich sind, tun Folgendes:
|
||||
|
||||
**Sie rechnen mit den schlechten Szenarien, bevor sie das Gute annehmen.** Ein Businessplan, der nur das Base-Case zeigt, ist kein Werkzeug — er ist Wunschdenken. Rechnen Sie explizit durch: Was passiert bei 40 Prozent Auslastung? Bei einem Bauverzug von sechs Monaten? Bei einem neuen Wettbewerber in Jahr drei?
|
||||
|
||||
**Sie bauen Puffer ein, nicht als Komfortpolster, sondern als betriebliche Notwendigkeit.** Liquide Reserven von mindestens sechs Monaten Fixkosten sind kein Luxus.
|
||||
|
||||
**Sie sichern Mietverträge und Finanzierungskonditionen von Anfang an sorgfältig ab.** Die Kosten für gute Rechts- und Finanzberatung sind verglichen mit dem Downside verschwindend gering.
|
||||
|
||||
**Sie planen für Wettbewerb.** Nicht indem sie auf keine Konkurrenz hoffen, sondern indem sie ein Produkt aufbauen, das Stammkunden bindet — durch Qualität, Community und Dienstleistung.
|
||||
<div class="article-cards">
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Schlechte Szenarien zuerst durchrechnen</span>
|
||||
<p class="article-card__body">Ein Businessplan, der nur das Base-Case zeigt, ist kein Werkzeug — er ist Wunschdenken. Was passiert bei 40 Prozent Auslastung? Bei sechs Monaten Bauverzug? Bei einem neuen Wettbewerber in Jahr drei?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Puffer als betriebliche Notwendigkeit</span>
|
||||
<p class="article-card__body">Liquide Reserven von mindestens sechs Monaten Fixkosten sind kein Luxus, sondern Pflicht. Baukostenpuffer ist eine Budgetlinie — kein optionales Polster.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Verträge von Anfang an absichern</span>
|
||||
<p class="article-card__body">Mietvertrag, Finanzierungskonditionen, Bürgschaftsumfang. Die Kosten für gute Rechts- und Finanzberatung in der Planungsphase sind verglichen mit dem Downside verschwindend gering.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--success">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Für Wettbewerb planen</span>
|
||||
<p class="article-card__body">Nicht indem man auf keine Konkurrenz hofft, sondern indem man ein Produkt aufbaut, das Stammkunden bindet — durch Qualität, Community und Dienstleistungsqualität.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Die Padelnomics-Investitionsrechnung
|
||||
|
||||
Der Padelnomics-Planer enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
|
||||
Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
|
||||
|
||||
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
|
||||
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||
</div>
|
||||
|
||||
@@ -138,17 +138,35 @@ Das Ergebnis ist ein Gesamtscore pro Standort, der einen strukturierten Vergleic
|
||||
|
||||
Die acht Kriterien oben bewerten konkrete Objekte. Bevor Sie aber mit der Objektsuche beginnen, lohnt ein Schritt zurück: In welcher Entwicklungsphase befindet sich der Markt in Ihrer Zielstadt? Die Antwort bestimmt, welche Betreiberstrategie überhaupt Aussicht auf Erfolg hat.
|
||||
|
||||
**Etablierte Märkte**: Buchungsplattformen zeigen durchgehende Vollauslastung zu Stoßzeiten, Wartelisten sind verbreitet, und die Nachfrage ist über jeden Zweifel hinaus belegt. Die Herausforderung liegt nicht mehr in der Nachfrage — sie liegt im Wettbewerb. Etablierte Betreiber haben Markenloyalität aufgebaut, günstige Flächen sind längst vergeben, und Bau- sowie Mietkosten spiegeln die Nachfragesituation wider. Wer in einem solchen Markt neu eintritt, braucht einen echten Differenzierungsansatz: eine bessere Standortlage innerhalb der Stadt, ein überlegenes Hallenprofil oder ein Gastronomie- und Coaching-Angebot, das die bestehenden Anlagen nicht haben. Das Eintrittsinvestment ist hoch — das Ertragspotenzial bei konsequenter Umsetzung aber auch. München ist das paradigmatische Beispiel für Deutschland.
|
||||
|
||||
**Wachstumsmärkte**: Die Nachfrage wächst sichtbar — Buchungszeiten füllen sich an Wochenenden, neue Anlagen werden regelmäßig eröffnet, und der Sport erreicht lokale Medienöffentlichkeit. Das Angebot hat die Nachfrage noch nicht vollständig eingeholt; in bestimmten Stadtteilen oder im Umland sind Versorgungslücken erkennbar. Das Risikoprofil ist geringer als in Frühmärkten, aber das Fenster für attraktive Flächen zu vertretbaren Konditionen schließt sich. Wer wartet, bis der Markt offensichtlich attraktiv ist, zahlt für dieses Wissen einen Aufpreis — in Form höherer Mieten, weniger Auswahl und mehr Konkurrenz beim Eintritt.
|
||||
|
||||
**Frühmärkte**: Geringes aktuelles Angebot, eine kleine aber wachsende Spielerbasis und ein noch nicht hinreichend bekannter Sport — die Rahmenbedingungen für günstigen Markteintritt sind vorhanden, aber Nachfrage muss aktiv aufgebaut werden, nicht abgeschöpft. Mietkosten sind niedriger, Standortauswahl größer. Der limitierende Faktor ist Geduld und Marketingfähigkeit: Anfängerkurse, Vereinskooperationen, lokale Ligen und die Konversion bestehender Tennisclubs sind die Instrumente, mit denen Betreiber in Frühmärkten Community und damit Auslastung aufbauen. Der Weg zur ersten Profitabilität ist länger — aber die Wettbewerbsposition, die in den ersten zwei Betriebsjahren aufgebaut wird, erweist sich oft als strukturell dauerhaft.
|
||||
<div class="article-cards">
|
||||
<div class="article-card article-card--established">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Etablierte Märkte</span>
|
||||
<p class="article-card__body">Buchungsplattformen zeigen durchgehende Vollauslastung zu Stoßzeiten, Wartelisten sind verbreitet. Die Herausforderung liegt im Wettbewerb: Etablierte Betreiber haben Markenloyalität aufgebaut, günstige Flächen sind vergeben. Neueintretende Betreiber brauchen echten Differenzierungsansatz. Eintrittsinvestment ist hoch — das Ertragspotenzial bei konsequenter Umsetzung ebenfalls. München ist das paradigmatische Beispiel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--growth">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Wachstumsmärkte</span>
|
||||
<p class="article-card__body">Die Nachfrage wächst sichtbar — Buchungszeiten füllen sich, neue Anlagen werden eröffnet. Das Angebot hat die Nachfrage noch nicht eingeholt; Versorgungslücken sind erkennbar. Das Fenster für attraktive Flächen zu vertretbaren Konditionen schließt sich. Wer wartet, zahlt den Aufpreis des offensichtlich attraktiven Markts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-card article-card--emerging">
|
||||
<div class="article-card__accent"></div>
|
||||
<div class="article-card__inner">
|
||||
<span class="article-card__title">Frühmärkte</span>
|
||||
<p class="article-card__body">Geringes Angebot, kleine aber wachsende Spielerbasis. Mietkosten niedriger, Standortauswahl größer — aber Nachfrage muss aktiv aufgebaut werden. Anfängerkurse, Vereinskooperationen, lokale Ligen und Konversion von Tennisclubs sind die zentralen Instrumente. Der Weg zur Profitabilität ist länger; die aufgebaute Wettbewerbsposition erweist sich oft als dauerhaft.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Bevor Sie in einer Stadt konkret nach Objekten suchen, sollten Sie deren Marktreife einordnen. Der Kriterienkatalog zeigt, ob ein bestimmtes Objekt geeignet ist; die Marktreife zeigt, welches Betreiberprofil und welche Strategie überhaupt die Voraussetzung für Erfolg ist.
|
||||
|
||||
Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier:
|
||||
|
||||
[→ Marktüberblick nach Land](/markets/germany)
|
||||
[→ Marktüberblick nach Land](/de/markets/germany)
|
||||
|
||||
---
|
||||
|
||||
@@ -156,4 +174,10 @@ Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografisch
|
||||
|
||||
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
|
||||
|
||||
[→ Standortanalyse starten]
|
||||
[→ Standortanalyse starten](/de/planner)
|
||||
|
||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
|
||||
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
|
||||
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
|
||||
<a href="/quote" class="btn">Angebot anfordern</a>
|
||||
</div>
|
||||
|
||||
67
data/content/articles/padel-zubehoer-de.md
Normal file
67
data/content/articles/padel-zubehoer-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padel-Zubehör: Das braucht jeder Spieler wirklich"
|
||||
slug: padel-zubehoer-de
|
||||
language: de
|
||||
url_path: /padel-zubehoer
|
||||
meta_description: "Welches Padel-Zubehör lohnt sich wirklich? Von Griffband und Vibrationsdämpfer bis zur Sporttasche — was ist nützlich, was ist Marketing?"
|
||||
---
|
||||
|
||||
# Padel-Zubehör: Das braucht jeder Spieler wirklich
|
||||
|
||||
<!-- TODO: Einleitung — Zubehör gibt es viel, sinnvoll ist wenig -->
|
||||
|
||||
Wer Padel ernsthafter betreibt, wird früh von Empfehlungen überhäuft: Griffband kaufen! Schutzhülle! Vibrationsdämpfer! Nicht alles davon ist sinnvoll — aber einiges tatsächlich unverzichtbar. Dieser Guide hilft dabei, nützliches Zubehör von überteuertem Marketing zu trennen.
|
||||
|
||||
---
|
||||
|
||||
## Das sinnvollste Zubehör im Überblick
|
||||
|
||||
[product-group:accessory]
|
||||
|
||||
---
|
||||
|
||||
## Griffband: Ja, unbedingt
|
||||
|
||||
<!-- TODO: Erklärung, welches Griffband sich lohnt -->
|
||||
|
||||
[product:platzhalter-griffband-amazon]
|
||||
|
||||
---
|
||||
|
||||
## Schläger-Schutzhülle: Ja, wenn man häufig transportiert
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Vibrationsdämpfer: Geschmackssache
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Sporttasche: Erst ab regelmäßigem Spiel
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie oft sollte man das Griffband wechseln?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Bei regelmäßigem Spielen empfehlen wir einen Wechsel alle 4–8 Wochen. Ein abgenutztes Griffband erhöht das Risiko, den Schläger wegzuschleudern, und mindert die Kontrolle.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Brauche ich eine spezielle Padeltasche?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Eine Padeltasche schützt den Schläger vor Beschädigungen beim Transport. Für gelegentliche Spieler reicht ein einfaches Cover. Wer mehrere Schläger trägt oder regelmäßig zum Club fährt, profitiert von einer Sporttasche mit gepolstertem Schlägerfach.
|
||||
|
||||
</details>
|
||||
70
data/content/articles/padelbaelle-vergleich-de.md
Normal file
70
data/content/articles/padelbaelle-vergleich-de.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Beste Padelbälle 2026: Test und Vergleich der populärsten Modelle"
|
||||
slug: padelbaelle-vergleich-de
|
||||
language: de
|
||||
url_path: /padelbaelle-vergleich
|
||||
meta_description: "Welche Padelbälle sind am besten? Wir vergleichen die beliebtesten Modelle nach Druckhaltigkeit, Spielgefühl und Preis-Leistungs-Verhältnis."
|
||||
---
|
||||
|
||||
# Beste Padelbälle 2026: Test und Vergleich der populärsten Modelle
|
||||
|
||||
<!-- TODO: Einleitung — warum Bälle oft unterschätzt werden -->
|
||||
|
||||
Der Ball ist das am häufigsten unterschätzte Equipment im Padel. Dabei entscheidet seine Druckhaltigkeit maßgeblich über das Spielgefühl. Ein Padelball verliert nach 4–6 Stunden intensivem Spiel merklich an Druck — und damit an Tempo, Kontrolle und Spaß.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Empfehlungen
|
||||
|
||||
[product-group:ball]
|
||||
|
||||
---
|
||||
|
||||
## Druckhaltigkeit: Was wirklich zählt
|
||||
|
||||
<!-- TODO: Erklärung des Druckverlusts + Testzeitraum -->
|
||||
|
||||
---
|
||||
|
||||
## Turnier- vs. Freizeitball
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Testsieger im Überblick
|
||||
|
||||
[product:platzhalter-ball-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie lange hält ein Padelball?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ein hochwertiger Padelball ist nach etwa 4–8 Stunden Spielzeit merklich weicher. Im Freizeitbereich merkt man den Unterschied oft erst später. Profis und ambitionierte Spieler wechseln Bälle bereits nach einem Set.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Muss ich WCT- oder FIP-zertifizierte Bälle kaufen?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Für den Freizeiteinsatz nein. Für Turniere und Ligaspiele ja — die meisten Ligen schreiben zugelassene Ballmodelle vor. Im Training können beliebige Qualitätsbälle verwendet werden.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie lagere ich Padelbälle richtig?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Kühl und trocken lagern, nicht im Auto lassen. Manche Spieler verwenden Druckbehälter, um den Druckverlust zu verlangsamen — das funktioniert tatsächlich für bereits angebrochene Dosen.
|
||||
|
||||
</details>
|
||||
67
data/content/articles/padelschlaeger-anfaenger-de.md
Normal file
67
data/content/articles/padelschlaeger-anfaenger-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padelschläger für Anfänger 2026: Die 5 besten Einstiegsmodelle"
|
||||
slug: padelschlaeger-anfaenger-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-anfaenger
|
||||
meta_description: "Welcher Padelschläger eignet sich für Anfänger? Unsere Empfehlungen für Einsteiger: verzeihendes Spielgefühl, robuste Verarbeitung, fairer Preis."
|
||||
---
|
||||
|
||||
# Padelschläger für Anfänger 2026: Die 5 besten Einstiegsmodelle
|
||||
|
||||
<!-- TODO: Einleitung, warum Anfängerschläger sich von Profimodellen unterscheiden (150–200 Wörter) -->
|
||||
|
||||
Für den Einstieg ins Padel braucht man keinen teuren Profischaft. Im Gegenteil: Die meisten Hochleistungsschläger sind für Anfänger kontraproduktiv — ihr kleines Sweetspot-Fenster bestraft Fehlschläge, die in der Lernphase normal sind. Ein guter Anfängerschläger ist leicht, hat eine runde Form und verzeiht ungenaue Treffpunkte.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Top-5 für Einsteiger
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Was macht einen guten Anfängerschläger aus?
|
||||
|
||||
<!-- TODO: Erklärung der relevanten Schläger-Eigenschaften (Form, Gewicht, Material) -->
|
||||
|
||||
### Schlägerkopfform: Rund schlägt Diamant
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### Gewicht: Leichter ist nicht immer besser
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### Material: EVA vs. Foam
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Unsere Empfehlung im Detail
|
||||
|
||||
[product:platzhalter-anfaenger-schlaeger-amazon]
|
||||
|
||||
<!-- TODO: Ausführliche Besprechung mit Praxistest -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Ab welchem Preis lohnt sich ein eigener Schläger?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Wer mehr als einmal pro Woche spielt, sollte in einen eigenen Schläger investieren. Leihschläger im Club sind oft abgenutzt und vermitteln ein falsches Spielgefühl. Ab 60–80 Euro gibt es solide Einsteigerschläger.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kann ich als Anfänger direkt mit einem 150-Euro-Schläger starten?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ja, sofern es sich um ein anfängerfreundliches Modell aus diesem Preisbereich handelt. Preisschilder allein sagen wenig — ein 150-Euro-Diamantschläger kann für Einsteiger schlechter sein als ein 70-Euro-Rundschläger.
|
||||
|
||||
</details>
|
||||
55
data/content/articles/padelschlaeger-defensiv-de.md
Normal file
55
data/content/articles/padelschlaeger-defensiv-de.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "Padelschläger für defensive Spieler: Die besten Kontrollschläger 2026"
|
||||
slug: padelschlaeger-defensiv-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-defensiv
|
||||
meta_description: "Die besten Padelschläger für defensive und kontrollbetonte Spieler. Runde und Tropfenform mit großem Sweetspot für sicheres Spiel vom Grundfeld."
|
||||
---
|
||||
|
||||
# Padelschläger für defensive Spieler: Die besten Kontrollschläger 2026
|
||||
|
||||
<!-- TODO: Einleitung zur defensiven Spielweise und warum der Schläger einen Unterschied macht -->
|
||||
|
||||
Im Padel entscheidet das Grundfeld. Wer vom hinteren Drittel sauber und kontrolliert spielen kann, zwingt den Gegner zu Fehlern. Für diesen Spielstil braucht man einen Schläger mit großem Sweetspot, weichem EVA-Kern und einer runden oder Tropfenform — nicht die auffälligsten Geräte, aber die effektivsten.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Empfehlungen für defensive Spieler
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Warum Kontrolle wichtiger ist als Power
|
||||
|
||||
<!-- TODO: Erklärung Spielstil + Schlägercharakteristik -->
|
||||
|
||||
---
|
||||
|
||||
## Testsieger im Detail
|
||||
|
||||
[product:platzhalter-defensiv-schlaeger-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Was ist der Unterschied zwischen einem Kontroll- und einem Powerschläger?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Kontrollschläger (runde Form, weicher Kern) vergrößern den Sweetspot und ermöglichen feingefühliges Spiel. Powerschläger (Diamantform, harter Kern) bieten mehr Hebelwirkung beim Smash, verzeihen aber weniger Fehlschläge.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Für welche Spielstufe sind Kontrollschläger geeignet?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Kontrollschläger sind für Anfänger, Freizeitspieler und taktisch orientierte Spieler aller Stufen geeignet. Auch viele erfahrene Spieler bevorzugen sie, weil Konsistenz auf Dauer mehr Punkte bringt als gelegentliche Powerschläge.
|
||||
|
||||
</details>
|
||||
67
data/content/articles/padelschlaeger-fortgeschrittene-de.md
Normal file
67
data/content/articles/padelschlaeger-fortgeschrittene-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
|
||||
slug: padelschlaeger-fortgeschrittene-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-fortgeschrittene
|
||||
meta_description: "Die besten Padelschläger für fortgeschrittene und ambitionierte Spieler. High-End-Modelle mit Carbon, Kevlar und ausgereifter Schlagbalance für Spieler ab 3.0."
|
||||
---
|
||||
|
||||
# Padelschläger für Fortgeschrittene: Die besten Modelle 2026
|
||||
|
||||
<!-- TODO: Einleitung — wann ist man bereit für einen Fortgeschrittenenschläger? -->
|
||||
|
||||
Ab einem gewissen Spielniveau lohnt sich der Griff zu einem anspruchsvolleren Schläger. Wer sauber trifft, kann von einer härteren Bespannung und einer präziseren Balance profitieren. Die Schläger in dieser Liste sind kein Selbstläufer — aber in den richtigen Händen ein echter Vorteil.
|
||||
|
||||
---
|
||||
|
||||
## Top-Schläger für Fortgeschrittene im Überblick
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Carbon, Kevlar, Glasfaser: Was steckt drin?
|
||||
|
||||
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
|
||||
|
||||
### Carbon-Rahmen
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### 3K vs. 12K Carbon
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### Kevlar-Einlagen
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Testbericht: Unser Empfehlungsschläger
|
||||
|
||||
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
|
||||
|
||||
<!-- TODO: Praxistest -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Wer regelmäßig spielt (2–3 Mal pro Woche), seit mindestens einem Jahr dabei ist und an Taktik und Technik arbeitet, kann von einem hochwertigeren Schläger profitieren. Für gelegentliche Spieler ist der Unterschied zu einem Mittelklassemodell kaum spürbar.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Nicht zwingend. Es gibt ausgezeichnete Modelle im 150–200-Euro-Segment, die professionell verarbeitete Carbon-Elemente enthalten. Alles über 300 Euro richtet sich meist an Spieler mit Wettkampfambitionen.
|
||||
|
||||
</details>
|
||||
55
data/content/articles/padelschlaeger-unter-100-de.md
Normal file
55
data/content/articles/padelschlaeger-unter-100-de.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "Padelschläger unter 100 Euro: Die besten günstigen Modelle 2026"
|
||||
slug: padelschlaeger-unter-100-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-unter-100
|
||||
meta_description: "Gute Padelschläger müssen nicht teuer sein. Die besten Modelle unter 100 Euro — mit echtem Spielgefühl, ohne Kompromisse bei der Verarbeitung."
|
||||
---
|
||||
|
||||
# Padelschläger unter 100 Euro: Die besten günstigen Modelle 2026
|
||||
|
||||
<!-- TODO: Einleitung — Gibt es wirklich gute Schläger für unter 100 Euro? -->
|
||||
|
||||
Wer sagt, dass Padel teuer sein muss? In der 50-100-Euro-Klasse gibt es Schläger, die sich von 200-Euro-Modellen im Freizeitspiel kaum unterscheiden. Der entscheidende Unterschied liegt oft im Material des Rahmens und im Kern — nicht im Spielgefühl.
|
||||
|
||||
---
|
||||
|
||||
## Die besten Schläger unter 100 Euro
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Was bekommt man unter 100 Euro?
|
||||
|
||||
<!-- TODO: Realistische Erwartungen setzen -->
|
||||
|
||||
---
|
||||
|
||||
## Unser Preisklassen-Tipp
|
||||
|
||||
[product:platzhalter-budget-schlaeger-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Sind günstige Padelschläger schlechter verarbeitet?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Nicht zwangsläufig. Im Bereich 60–100 Euro findet man solide Fiberglas-Schläger bekannter Marken. Der Hauptunterschied zu teureren Modellen ist das Rahmenmaterial (kein Carbon) und ein schlichtes Design.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Lohnt es sich, für einen Einsteiger 100 Euro auszugeben?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ja, wenn er weiß, dass er das Spiel ernsthafter betreiben will. Für einen ersten Test reicht auch ein 50-Euro-Schläger — aber wer nach der ersten Saison weiterspielen will, wird früh aufwerten wollen.
|
||||
|
||||
</details>
|
||||
61
data/content/articles/padelschuhe-test-de.md
Normal file
61
data/content/articles/padelschuhe-test-de.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Padelschuhe Test 2026: Die besten Schuhe für Sand- und Kunstgras"
|
||||
slug: padelschuhe-test-de
|
||||
language: de
|
||||
url_path: /padelschuhe-test
|
||||
meta_description: "Welche Padelschuhe sind am besten? Unser Test der beliebtesten Modelle — für Sand, Kunstgras und Kunststoffbelag mit optimaler Dämpfung und Stabilität."
|
||||
---
|
||||
|
||||
# Padelschuhe Test 2026: Die besten Schuhe für Sand- und Kunstgras
|
||||
|
||||
<!-- TODO: Einleitung — warum normale Tennisschuhe nicht reichen -->
|
||||
|
||||
Padelschuhe werden häufig unterschätzt. Auf dem Sandbelag des Padel-Courts braucht man eine völlig andere Sohle als auf Tennishartplatz oder Hallenboden. Ein falscher Schuh erhöht nicht nur das Verletzungsrisiko — er kostet auch Punkte, weil man in Kurven wegrutscht.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Top-Empfehlungen
|
||||
|
||||
[product-group:shoe]
|
||||
|
||||
---
|
||||
|
||||
## Welche Sohle für welchen Belag?
|
||||
|
||||
<!-- TODO: Sohlentypen und Untergrundtabelle -->
|
||||
|
||||
| Belag | Empfohlene Sohle |
|
||||
|---|---|
|
||||
| Sand (feiner Quarzsand) | Fishbone / Fischgrät |
|
||||
| Kunstgras | Multicourt / Omnidirectional |
|
||||
| Kunststoff/Beton | Glatte Multicourt-Sohle |
|
||||
|
||||
---
|
||||
|
||||
## Testbericht: Bester Allround-Schuh
|
||||
|
||||
[product:platzhalter-padelschuh-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Kann ich Tennisschuhe für Padel verwenden?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Für den gelegentlichen Einstieg ja. Auf Dauer ist es nicht empfehlenswert: Tennisschuhe bieten auf Sand zu wenig Halt, und die Abnutzung ist höher. Nach 3–4 Monaten regelmäßigen Spielens zahlen sich dedizierte Padelschuhe aus.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie erkenne ich verschlissene Padelschuhe?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Wenn die Außenfläche der Sohle glatt wird oder das Profil auf unter 2 mm abgenutzt ist, verliert der Schuh seinen Halt. Bei Padel ist das gefährlicher als bei vielen anderen Sportarten, weil häufige Richtungswechsel auf losem Sand stattfinden.
|
||||
|
||||
</details>
|
||||
@@ -59,10 +59,11 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
healthcheck:
|
||||
@@ -81,10 +82,11 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -97,10 +99,11 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -114,10 +117,11 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
healthcheck:
|
||||
@@ -136,10 +140,11 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -152,10 +157,11 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Padelnomics — Marketing Master Doc
|
||||
|
||||
> Living doc. Update state column as things progress. Last updated: 2026-02-22.
|
||||
> Living doc. Update state column as things progress. Last updated: 2026-03-04.
|
||||
|
||||
---
|
||||
|
||||
@@ -216,9 +216,9 @@ The moat compounds over time — this is critical to long-term defensibility.
|
||||
|
||||
| Channel | Approach | State |
|
||||
|---------|----------|-------|
|
||||
| **LinkedIn** | Founder posts, thought leadership, padel community | [ ] Not started |
|
||||
| **Reddit** | r/padel, r/entrepreneur — seeding calculator, articles | [ ] Not started |
|
||||
| **Facebook Groups** | Padel business groups, sports entrepreneur communities | [ ] Not started |
|
||||
| **LinkedIn** | Founder posts, thought leadership, padel community | [~] First post published |
|
||||
| **Reddit** | r/padel, r/sweatystartup, r/entrepreneur, r/tennis, r/smallbusiness, r/pickleball, r/CRE — seeding calculator, articles | [~] Active in 7 subreddits |
|
||||
| **Facebook Groups** | Padel business groups, sports entrepreneur communities | [~] Active in 2-3 groups |
|
||||
|
||||
### Borrowed (Month 2+)
|
||||
|
||||
|
||||
89
docs/gtm-day-one.md
Normal file
89
docs/gtm-day-one.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# GTM — Day One Action Plan
|
||||
|
||||
> Created: 2026-03-04. Do these in order. Total time: ~4–5 hours.
|
||||
|
||||
---
|
||||
|
||||
## Right Now (1–2 hours, highest leverage)
|
||||
|
||||
### 1. Submit sitemap to Google Search Console + Bing Webmaster Tools
|
||||
|
||||
You have 80 programmatic city articles sitting unindexed. Every day without indexing is wasted compound time.
|
||||
|
||||
- [search.google.com/search-console](https://search.google.com/search-console) → Add property → Submit sitemap
|
||||
- [bing.com/webmasters](https://www.bing.com/webmasters) (Bing also feeds DuckDuckGo, Ecosia, Yahoo)
|
||||
- Your SEO hub already supports both — just add the env vars
|
||||
|
||||
### 2. Publish SEO articles on prod
|
||||
|
||||
Run `seed_content --generate` from admin or CLI. Those 80 city pages (40 cities × EN+DE) are the primary organic traffic engine. Until they're live and crawlable, they generate zero value.
|
||||
|
||||
### 3. Index the planner in Google
|
||||
|
||||
Make sure `/en/calculator` and `/de/rechner` are in the sitemap and crawlable. This is the #1 free tool — the entire PLG funnel starts here. Check canonical tags and hreflang are correct.
|
||||
|
||||
---
|
||||
|
||||
## This Afternoon (2–3 hours, seed distribution)
|
||||
|
||||
### 4. First LinkedIn post
|
||||
|
||||
Data-driven insight from the pipeline. See `docs/social-posts.md` for the full post.
|
||||
|
||||
### 5. Post in Reddit communities
|
||||
|
||||
- **r/padel**: Free calculator angle — genuinely useful tool
|
||||
- **r/entrepreneur**: Indie maker angle — "built this with real market data"
|
||||
- **r/smallbusiness**: Business planning tool angle
|
||||
- **r/tennis**: Cross-sport angle — tennis clubs adding padel courts
|
||||
|
||||
See `docs/social-posts.md` for all posts ready to copy-paste.
|
||||
|
||||
### 6. Share in 2–3 Facebook padel business groups
|
||||
|
||||
Same angle as Reddit — free tool, no hard sell. Search for:
|
||||
- "Padel Business" groups
|
||||
- "Padel Club Owners" groups
|
||||
- "Padel Deutschland" / "Padel Germany" groups
|
||||
|
||||
---
|
||||
|
||||
## This Evening (1 hour, set up compounding assets)
|
||||
|
||||
### 7. Verify Resend production API key
|
||||
|
||||
Test a real magic link email. Until email works in prod, you can't capture traffic.
|
||||
|
||||
### 8. Wipe test suppliers
|
||||
|
||||
Delete the 5 `example.com` entries. Empty directory with "Be the first to list" > obviously fake data.
|
||||
|
||||
### 9. Request indexing for top 5 city pages
|
||||
|
||||
After GSC is set up, use "Request Indexing" manually for highest-value pages:
|
||||
- `/de/markets/berlin`, `/de/markets/muenchen`, `/de/markets/hamburg`
|
||||
- `/en/markets/london`, `/en/markets/madrid`
|
||||
|
||||
Google prioritizes manually requested URLs — can appear in search within days vs. weeks.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to do today
|
||||
|
||||
- ~~"State of Padel" report~~ — multi-day effort
|
||||
- ~~Supplier outreach~~ — site needs to be live + articles indexed first
|
||||
- ~~Copy/CRO optimization~~ — premature, get traffic first
|
||||
- ~~Paid ads~~ — excluded in channel strategy
|
||||
|
||||
---
|
||||
|
||||
## Expected outcome
|
||||
|
||||
If you do steps 1–9 today:
|
||||
|
||||
- 80 pages submitted for indexing (organic traffic starts in 1–3 weeks)
|
||||
- 3–5 social posts seeding traffic immediately
|
||||
- Planner discoverable and shareable
|
||||
- Email capture working for when traffic arrives
|
||||
|
||||
**Single highest-leverage action: publish the articles + submit the sitemap.** Everything else is distribution on top of that foundation.
|
||||
91
docs/reddit-communities.md
Normal file
91
docs/reddit-communities.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Reddit Communities — Padelnomics Distribution
|
||||
|
||||
> Permanent reference for Reddit distribution. Subreddits ranked by relevance + size.
|
||||
> Created: 2026-03-04. Review monthly — subreddit rules change.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Post Here First
|
||||
|
||||
High relevance, receptive to tools/data, proven padel or business-planning interest.
|
||||
|
||||
| Subreddit | Size | Angle | Notes |
|
||||
|-----------|------|-------|-------|
|
||||
| r/padel | ~20K | Free calculator, data insights, answer existing biz threads | Player community — lead with the sport, not the product. Helpful tone only. |
|
||||
| r/sweatystartup | ~56-81K | "Best brick-and-mortar sports opportunity" with unit economics | Loves concrete P&L numbers. Show CAPEX/OPEX/payback, not vision. |
|
||||
| r/tennis | ~2M | Tennis club court conversion trends + data | Huge audience. Angle: "your club is probably already thinking about this." |
|
||||
| r/smallbusiness | ~2.2M | Free business planning tool for sports facilities | Practical, no-hype tone. Lead with the tool, not the market thesis. |
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Test With One Post Each
|
||||
|
||||
Potentially high-value but less proven fit. Post once, measure engagement, double down if it works.
|
||||
|
||||
| Subreddit | Size | Angle | Notes |
|
||||
|-----------|------|-------|-------|
|
||||
| r/entrepreneur | ~4.8M | "Bloomberg for padel" indie builder story | Loves "I built X" posts with real data. Show the data pipeline, not just the product. |
|
||||
| r/CommercialRealEstate | ~44K | Sports venue site selection as niche CRE | Small but highly targeted. Angle: alternative asset class with data backing. |
|
||||
| r/realestateinvesting | ~1.2M | Alternative commercial RE asset class | Broader audience. Frame padel as "the new self-storage" — boring but profitable. |
|
||||
| r/pickleball | ~30K | Padel vs pickleball facility economics comparison | Comparative angle works. Don't trash pickleball — frame as "here's what the padel side looks like." |
|
||||
| r/gymowners | Small | Cross-reference gym location frameworks with padel data | Niche. Test if gym owners see padel as a complementary or competing asset. |
|
||||
| r/padelUSA | <5K | US-specific demand data | Tiny but highly relevant. US padel market is nascent — early authority opportunity. |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Monitor Only
|
||||
|
||||
Read these for trends and conversations. Don't post unless a specific thread is a perfect fit for a data-backed comment.
|
||||
|
||||
- r/business — too generic, self-promo gets buried
|
||||
- r/startups — SaaS-focused, padel doesn't fit the narrative
|
||||
- r/SaaS — pure software community, facility business is off-topic
|
||||
- r/venturecapital — wrong audience for bootstrapped niche tool
|
||||
- r/sports — massive, low engagement on niche content
|
||||
|
||||
---
|
||||
|
||||
## Key Gap
|
||||
|
||||
No subreddit exists for padel facility operators or business owners. If community forms organically around Padelnomics content (comments like "where can I discuss this more?"), consider creating **r/padelbusiness** later. Don't force it — let demand signal the timing.
|
||||
|
||||
---
|
||||
|
||||
## Posting Rules
|
||||
|
||||
1. **One link per post, at the end.** Never in the title.
|
||||
2. **Engage with every comment for 24 hours** after posting. This is where the real value is.
|
||||
3. **No cross-posting.** Each post is unique to the subreddit's culture and tone.
|
||||
4. **If a post gets removed, don't repost.** Move to the next subreddit. Respect mod decisions.
|
||||
5. **Read each subreddit's rules before posting.** Some ban self-promotion entirely. Some require flair. Some have minimum account age/karma requirements.
|
||||
6. **Never post more than one subreddit per day.** Spread it out. Reddit's spam detection flags rapid multi-sub posting.
|
||||
7. **Comment on existing threads first.** Build karma and presence in a sub before dropping your own post.
|
||||
|
||||
---
|
||||
|
||||
## UTM Tracking Format
|
||||
|
||||
All Reddit links use this format:
|
||||
|
||||
```
|
||||
https://padelnomics.io/<path>?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_<subreddit>
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel`
|
||||
- `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_sweatystartup`
|
||||
- `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_cre`
|
||||
|
||||
---
|
||||
|
||||
## Measuring Success
|
||||
|
||||
| Metric | Good | Great |
|
||||
|--------|------|-------|
|
||||
| Post upvotes | 10+ | 50+ |
|
||||
| Comments | 5+ | 20+ |
|
||||
| UTM clicks (GA) | 20+ per post | 100+ per post |
|
||||
| Planner completions from Reddit | 5+ per post | 20+ per post |
|
||||
| Email captures from Reddit | 2+ per post | 10+ per post |
|
||||
|
||||
Track weekly in a simple spreadsheet. Drop subreddits that produce zero clicks after 2 posts.
|
||||
106
docs/reddit-posting-plan.md
Normal file
106
docs/reddit-posting-plan.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Reddit Posting Plan — Launch Sequence
|
||||
|
||||
> Day-by-day posting schedule. One post per day, engage for 24 hours after each.
|
||||
> Created: 2026-03-04. See `docs/reddit-communities.md` for full subreddit research.
|
||||
|
||||
---
|
||||
|
||||
## Posting Sequence
|
||||
|
||||
| Day | Subreddit | Post Title | Angle | UTM |
|
||||
|-----|-----------|-----------|-------|-----|
|
||||
| 1 | r/padel | "I built a free padel court ROI calculator — feedback welcome" | Free tool, genuinely helpful | `utm_content=r_padel` |
|
||||
| 2 | r/sweatystartup | "25K venues analyzed — which cities are undersupplied for padel" | Unit economics, brick-and-mortar opportunity | `utm_content=r_sweatystartup` |
|
||||
| 3 | r/entrepreneur | "I'm building the 'Bloomberg for padel' — tracking 10,127 facilities across 17 countries" | Indie builder story with real data | `utm_content=r_entrepreneur` |
|
||||
| 4 | r/tennis | "Data on padel facility economics — useful for tennis clubs considering adding courts" | Tennis club conversion data | `utm_content=r_tennis` |
|
||||
| 5 | r/smallbusiness | "Free business planning tool for anyone looking at opening a sports facility" | Practical tool for real decisions | `utm_content=r_smallbusiness` |
|
||||
| 7 | r/pickleball | "Padel vs pickleball facility economics — a data comparison" | Comparative, respectful of pickleball | `utm_content=r_pickleball` |
|
||||
| 10 | r/CommercialRealEstate | "Sports venue site selection — data on underserved markets" | Alternative CRE asset class | `utm_content=r_cre` |
|
||||
|
||||
Day 6 and days 8-9 are rest days for engaging with comments on previous posts.
|
||||
|
||||
---
|
||||
|
||||
## Full UTM Format
|
||||
|
||||
Every Reddit link follows this exact format:
|
||||
|
||||
```
|
||||
https://padelnomics.io/<path>?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=<value>
|
||||
```
|
||||
|
||||
| Subreddit | utm_content value |
|
||||
|-----------|-------------------|
|
||||
| r/padel | `r_padel` |
|
||||
| r/sweatystartup | `r_sweatystartup` |
|
||||
| r/entrepreneur | `r_entrepreneur` |
|
||||
| r/tennis | `r_tennis` |
|
||||
| r/smallbusiness | `r_smallbusiness` |
|
||||
| r/pickleball | `r_pickleball` |
|
||||
| r/CommercialRealEstate | `r_cre` |
|
||||
|
||||
---
|
||||
|
||||
## Post Content
|
||||
|
||||
Full post text is in `docs/social-posts.md`. Before posting, replace `[LINK]` placeholders with the correct UTM-tagged URL:
|
||||
|
||||
| Post | Link to |
|
||||
|------|---------|
|
||||
| r/padel | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel` |
|
||||
| r/sweatystartup | `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_sweatystartup` |
|
||||
| r/entrepreneur | `https://padelnomics.io/en/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_entrepreneur` |
|
||||
| r/tennis | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_tennis` |
|
||||
| r/smallbusiness | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_smallbusiness` |
|
||||
| r/pickleball | `https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_pickleball` |
|
||||
| r/CommercialRealEstate | `https://padelnomics.io/en/markets?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_cre` |
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One link per post, at the end.** Never in the title.
|
||||
2. **Engage with every comment for 24 hours** after posting.
|
||||
3. **No cross-posting.** Each post is written uniquely for its subreddit's culture.
|
||||
4. **If a post gets removed, don't repost.** Move to the next subreddit.
|
||||
5. **Read subreddit rules before posting.** Check for self-promotion policies, flair requirements, minimum karma.
|
||||
6. **Comment on 2-3 existing threads** in a subreddit before making your own post (builds credibility).
|
||||
7. **Never mention other posts.** Each community should feel like they're getting a unique share.
|
||||
|
||||
---
|
||||
|
||||
## Engagement Playbook
|
||||
|
||||
### When you get comments:
|
||||
|
||||
- **"How accurate is this?"** — Share methodology: real market data from OpenStreetMap, Playtomic, Eurostat. Not generic assumptions.
|
||||
- **"What about [city]?"** — Run the planner for their city, share the numbers. This is high-value personalized engagement.
|
||||
- **"I'm actually looking at opening a facility"** — Offer to walk through the planner with them. Ask about their timeline, location, budget. This is a lead.
|
||||
- **"This is just an ad"** — Don't get defensive. Say "Fair point — I built this and wanted feedback. The tool is free with no signup, so figured it might be useful here."
|
||||
- **"What's your business model?"** — Be transparent: "Free calculator, paid market intelligence for serious investors, supplier directory for builders."
|
||||
|
||||
### When a post gets traction (50+ upvotes):
|
||||
|
||||
- Reply with additional data points to keep the thread alive
|
||||
- Answer every question, even late ones
|
||||
- Don't edit the original post to add more links
|
||||
|
||||
---
|
||||
|
||||
## Tracking
|
||||
|
||||
After each post, log:
|
||||
|
||||
| Field | Example |
|
||||
|-------|---------|
|
||||
| Date posted | 2026-03-04 |
|
||||
| Subreddit | r/padel |
|
||||
| Post URL | reddit.com/r/padel/... |
|
||||
| Upvotes (24hr) | 15 |
|
||||
| Comments (24hr) | 7 |
|
||||
| UTM clicks (GA, 7d) | 42 |
|
||||
| Planner starts (7d) | 12 |
|
||||
| Emails captured (7d) | 3 |
|
||||
| Removed? | No |
|
||||
|
||||
Review after Day 10. Double down on subreddits that drove clicks. Drop ones that didn't.
|
||||
150
docs/seo-content-calendar.md
Normal file
150
docs/seo-content-calendar.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# SEO Content Calendar — First 30 Days
|
||||
|
||||
> 4-week content plan covering programmatic SEO deployment, cornerstone articles, and data-driven content.
|
||||
> Created: 2026-03-04.
|
||||
|
||||
---
|
||||
|
||||
## Week 1 — Foundation (March 4-10)
|
||||
|
||||
Get the existing 80 pages indexed and write the first cornerstone article.
|
||||
|
||||
| Day | Task | Owner | State |
|
||||
|-----|------|-------|-------|
|
||||
| Mon | Publish 80 programmatic city articles (40 cities x EN+DE) | Deploy | [ ] |
|
||||
| Mon | Submit sitemap to Google Search Console | Manual | [ ] |
|
||||
| Mon | Submit sitemap to Bing Webmaster Tools | Manual | [ ] |
|
||||
| Tue | Request manual indexing for top 10 pages in GSC | Manual | [ ] |
|
||||
| Tue | Verify hreflang tags and canonical URLs on all city pages | Audit | [ ] |
|
||||
| Wed-Fri | Write Article #1: "Is Padel Still a Good Investment in 2026?" | Editorial | [ ] |
|
||||
| Fri | Publish Article #1, add to sitemap | Deploy | [ ] |
|
||||
|
||||
**Top 10 pages for manual indexing:**
|
||||
1. `/de/markets/berlin`
|
||||
2. `/de/markets/muenchen`
|
||||
3. `/de/markets/hamburg`
|
||||
4. `/en/markets/london`
|
||||
5. `/en/markets/madrid`
|
||||
6. `/en/calculator`
|
||||
7. `/de/rechner`
|
||||
8. `/en/markets/paris`
|
||||
9. `/de/markets/frankfurt`
|
||||
10. `/de/markets/koeln`
|
||||
|
||||
---
|
||||
|
||||
## Week 2 — Cornerstone Content (March 11-17)
|
||||
|
||||
Two high-value articles targeting decision-stage keywords. Internal linking pass connects everything.
|
||||
|
||||
| Day | Task | Owner | State |
|
||||
|-----|------|-------|-------|
|
||||
| Mon-Tue | Write Article #2: "How Much Does It Cost to Open a Padel Hall in Germany?" | Editorial | [ ] |
|
||||
| Wed | Publish Article #2 | Deploy | [ ] |
|
||||
| Thu-Fri | Write Article #3: "What Banks Want to See in a Padel Business Plan" | Editorial | [ ] |
|
||||
| Fri | Publish Article #3 | Deploy | [ ] |
|
||||
| Sat | Internal linking pass: city articles -> cornerstone articles -> planner | Technical | [ ] |
|
||||
|
||||
### Article #2 — Target Keywords
|
||||
- "padel halle kosten" / "padel court cost germany"
|
||||
- "padel halle eroeffnen kosten" / "how much to open padel hall"
|
||||
- "padel anlage investition"
|
||||
|
||||
### Article #3 — Target Keywords
|
||||
- "padel business plan" / "padel halle business plan"
|
||||
- "padel halle finanzierung" / "padel financing"
|
||||
- "bank business plan padel"
|
||||
|
||||
### Internal Linking Structure
|
||||
```
|
||||
City article (e.g., /markets/berlin)
|
||||
-> "How much does it cost?" (Article #2)
|
||||
-> "Plan your facility" (/calculator)
|
||||
|
||||
Article #2 (Cost breakdown)
|
||||
-> "Build your business plan" (/calculator)
|
||||
-> "What banks want to see" (Article #3)
|
||||
-> City-specific examples (/markets/muenchen, /markets/hamburg)
|
||||
|
||||
Article #3 (Bank requirements)
|
||||
-> "Generate your business plan" (/calculator)
|
||||
-> "Check market data for your city" (/markets)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Week 3 — Data-Driven Content (March 18-24)
|
||||
|
||||
Leverage the pipeline data for unique content nobody else can produce.
|
||||
|
||||
| Day | Task | Owner | State |
|
||||
|-----|------|-------|-------|
|
||||
| Mon-Wed | Write "Top 50 Underserved Locations for Padel in Europe" | Editorial | [ ] |
|
||||
| Wed | Publish Top 50 article | Deploy | [ ] |
|
||||
| Thu-Fri | Build Gemeinde-level pSEO template (targets "Padel in [Ort]") | Technical | [ ] |
|
||||
| Fri | Generate first batch of Gemeinde pages (top 20 locations) | Deploy | [ ] |
|
||||
|
||||
### Top 50 Article
|
||||
- Source data from `location_opportunity_profile` in the serving layer
|
||||
- Rank by opportunity score, filter to locations with zero existing facilities
|
||||
- Include mini-profiles: population, income level, nearest existing facility, opportunity score
|
||||
- Embed interactive map if possible, otherwise static top-50 table
|
||||
- Target keywords: "where to open padel", "best locations padel europe", "padel market gaps"
|
||||
|
||||
### Gemeinde-Level pSEO
|
||||
- Template targets: "Padel in [Ort]" / "Padel [Gemeinde]"
|
||||
- Zero SERP competition confirmed for most German municipalities
|
||||
- Content: local demographics, nearest facilities, opportunity score, CTA to planner
|
||||
- Start with top 20 highest-opportunity Gemeinden, expand weekly
|
||||
|
||||
---
|
||||
|
||||
## Week 4 — Authority Building (March 25-31)
|
||||
|
||||
Establish Padelnomics as the data authority. Begin email-gated content for list building.
|
||||
|
||||
| Day | Task | Owner | State |
|
||||
|-----|------|-------|-------|
|
||||
| Mon-Wed | Write "State of Padel Q1 2026" report | Editorial | [ ] |
|
||||
| Wed | Design PDF layout (WeasyPrint or similar) | Technical | [ ] |
|
||||
| Thu | Publish report landing page (email-gated download) | Deploy | [ ] |
|
||||
| Thu | Promote Market Score methodology page via social | Social | [ ] |
|
||||
| Fri | Begin link building via Reddit/LinkedIn engagement | Social | [ ] |
|
||||
| Ongoing | Monitor GSC for indexing progress, fix crawl errors | Technical | [ ] |
|
||||
|
||||
### State of Padel Q1 2026 Report
|
||||
- Executive summary of European padel market
|
||||
- Facility count by country (from pipeline data)
|
||||
- Growth trends (year-over-year where data exists)
|
||||
- Top opportunity markets (from opportunity scoring)
|
||||
- Investment economics summary (from planner defaults)
|
||||
- Email-gated: free download in exchange for email address
|
||||
- Promote via LinkedIn, Reddit, and direct outreach to industry contacts
|
||||
|
||||
---
|
||||
|
||||
## Content Inventory (End of Month 1)
|
||||
|
||||
| Type | Count | State |
|
||||
|------|-------|-------|
|
||||
| Programmatic city articles (EN+DE) | 80 | Deployed Week 1 |
|
||||
| Cornerstone articles | 3 | Published Weeks 1-2 |
|
||||
| Data-driven article (Top 50) | 1 | Published Week 3 |
|
||||
| Gemeinde-level pSEO pages | 20+ | Started Week 3 |
|
||||
| Gated report (State of Padel) | 1 | Published Week 4 |
|
||||
| **Total indexable pages** | **105+** | |
|
||||
|
||||
---
|
||||
|
||||
## SEO KPIs — End of Month 1
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Pages indexed (GSC) | 80+ of 105 |
|
||||
| Organic impressions | 500+ |
|
||||
| Organic clicks | 50+ |
|
||||
| Average position (target keywords) | Top 50 |
|
||||
| Email captures from gated report | 50+ |
|
||||
| Backlinks acquired | 3+ |
|
||||
|
||||
These are conservative baselines. Programmatic pages in zero-competition niches can index and rank faster than typical content.
|
||||
153
docs/social-posts-de.md
Normal file
153
docs/social-posts-de.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Social Posts — Deutsche Versionen
|
||||
|
||||
> Fertige Posts zum Rauskopieren. Domain: padelnomics.io
|
||||
> Erstellt: 2026-03-04.
|
||||
>
|
||||
> Reddit-Posts bleiben auf Englisch (englischsprachige Subreddits).
|
||||
> Diese Datei enthält LinkedIn- und Facebook-Posts auf Deutsch.
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn Post #1 — Marktdaten
|
||||
|
||||
> Ziel: Glaubwürdigkeit aufbauen + Traffic auf den Rechner lenken.
|
||||
|
||||
```
|
||||
10.127 Padel-Anlagen in 17 Ländern — wir haben sie alle erfasst.
|
||||
|
||||
Was dabei auffällt:
|
||||
|
||||
→ Italien führt mit 3.069 Anlagen. Mehr als Spanien (2.241).
|
||||
→ Portugal hat den reifsten Padel-Markt weltweit (Maturity Score 45,2/100) — bei „nur" 506 Anlagen.
|
||||
→ Deutschland: 359 Anlagen für 84 Mio. Einwohner. Spanien: 2.241 für 47 Mio.
|
||||
|
||||
Diese Lücke ist die Chance.
|
||||
|
||||
Wir haben 15.390 Standorte ohne Padel-Angebot identifiziert, die hohes Potenzial zeigen. Hamburg, München und Frankfurt stehen in Deutschland ganz oben.
|
||||
|
||||
Für alle, die über eine eigene Padel-Anlage nachdenken oder jemanden beraten: Wir haben einen kostenlosen ROI-Rechner gebaut, der mit echten Marktdaten die Kosten, Umsätze und Amortisation für jede Stadt in Europa modelliert.
|
||||
|
||||
Ohne Anmeldung. Einfach rechnen.
|
||||
|
||||
→ https://padelnomics.io/de/planner/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marktdaten
|
||||
|
||||
#padel #sportbusiness #marktdaten #unternehmertum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn Post #2 — Standortanalyse (Tag 2–3 posten)
|
||||
|
||||
```
|
||||
Die 5 am stärksten unterversorgten Städte für Padel in Europa:
|
||||
|
||||
1. Hamburg — 1,85 Mio. Einwohner, keine einzige Padel-Anlage
|
||||
2. München — 1,26 Mio. Einwohner, starke Sportkultur, kaum Angebot
|
||||
3. Bergen (Norwegen) — 294.000 Einwohner, Opportunity Score: 87,5/100
|
||||
4. Graz (Österreich) — 303.000 Einwohner, null Courts, hohes Einkommen
|
||||
5. Genf (Schweiz) — 202.000 Einwohner, null Courts, höchste Kaufkraft
|
||||
|
||||
Keine Schätzungen. Wir bewerten 143.877 Standorte in Europa anhand von Bevölkerungsdichte, Einkommensdaten, bestehendem Angebot und Sportinfrastruktur.
|
||||
|
||||
Der Padel-Markt wächst von 25.000 auf über 50.000 Anlagen weltweit. Die Frage ist nicht ob — sondern wo.
|
||||
|
||||
→ Daten für eure Stadt: https://padelnomics.io/de/markets?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_standortanalyse
|
||||
|
||||
#padel #marktanalyse #sportsinvestment #immobilien
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn Post #3 — Gründerstory (optional, Woche 2)
|
||||
|
||||
```
|
||||
Vor einem Jahr habe ich angefangen, den europäischen Padel-Markt systematisch zu erfassen.
|
||||
|
||||
Der Auslöser: Jeder, der eine Padel-Halle plant, trifft eine Entscheidung im sechsstelligen Bereich — und hat dafür keine belastbaren Daten. Kein zentrales Marktbild. Keine vergleichbaren Kennzahlen. Nur Excel und Bauchgefühl.
|
||||
|
||||
Daraus ist Padelnomics entstanden: eine Datenplattform für die Padel-Branche.
|
||||
|
||||
Was heute live ist:
|
||||
→ Kostenloser ROI-Rechner mit stadtspezifischen Realdaten
|
||||
→ 80 Marktanalysen für Städte in 17 Ländern
|
||||
→ Standortbewertung für 143.877 Orte in Europa
|
||||
→ Anbieterverzeichnis für Bau und Ausstattung
|
||||
|
||||
Die Daten kommen aus OpenStreetMap, Playtomic, Eurostat und Zensusdaten — automatisch aggregiert und bewertet.
|
||||
|
||||
Noch am Anfang, aber der Datenvorsprung wächst jeden Tag.
|
||||
|
||||
→ https://padelnomics.io/de/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_gruenderstory
|
||||
|
||||
#padel #startup #datenplattform #sportbusiness
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Facebook — Padel-Gruppen (Deutschland/DACH)
|
||||
|
||||
> Ton: locker, hilfsbereit, kurz. Kein Pitch.
|
||||
|
||||
**Titel (falls die Gruppe Titel erlaubt):** Kostenloser Padel-Rechner mit echten Marktdaten
|
||||
|
||||
```
|
||||
Moin zusammen,
|
||||
|
||||
ich hab einen kostenlosen Finanzplanungs-Rechner für Padel-Anlagen gebaut. CAPEX, laufende Kosten, Umsatzprognose — und am Ende eine 5-Jahres-GuV mit Amortisation.
|
||||
|
||||
Der Unterschied zu den üblichen Excel-Vorlagen: Der Rechner befüllt sich automatisch mit echten Daten für euren Standort. Mieten, Nebenkosten, Genehmigungsgebühren — alles stadtspezifisch, basierend auf Daten aus 17 Ländern.
|
||||
|
||||
Keine Anmeldung, kostenlos.
|
||||
|
||||
→ https://padelnomics.io/de/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_padel_de
|
||||
|
||||
Feedback ist willkommen — gerade von Leuten, die den Planungsprozess schon hinter sich haben und wissen, welche Zahlen wirklich zählen.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Facebook — Tennisvereine / Sportvereine (DACH)
|
||||
|
||||
> Ziel: Tennisvereine, die über Padel-Courts nachdenken.
|
||||
|
||||
```
|
||||
Falls euer Verein gerade über Padel-Courts nachdenkt (und viele tun das): Ich hab ein kostenloses Tool gebaut, das die Wirtschaftlichkeit durchrechnet.
|
||||
|
||||
→ Investitionskosten für 2–6 Courts an bestehenden Anlagen
|
||||
→ Umsatzprognose auf Basis realer Auslastungs- und Preisdaten
|
||||
→ Laufende Kosten für euren konkreten Standort
|
||||
→ Amortisation und ROI-Kennzahlen
|
||||
|
||||
Ein paar Zahlen aus unseren Daten:
|
||||
- Durchschnittliche Auslastung in reifen Märkten: 60–75 %
|
||||
- Outdoor-Anlage mit 4 Courts: 200.000–350.000 €
|
||||
- Indoor: 700.000–3 Mio. € je nach Bauweise
|
||||
- Tennisvereine, die 2 Plätze umrüsten, sehen typischerweise nach 18–30 Monaten Amortisation
|
||||
|
||||
Keine Anmeldung nötig.
|
||||
|
||||
→ https://padelnomics.io/de/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_tennis_de
|
||||
|
||||
Kann gern Daten zu einzelnen Städten oder Regionen teilen, wenn ihr etwas Konkretes prüft.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Posting-Zeitplan
|
||||
|
||||
| Tag | Plattform | Post |
|
||||
|-----|-----------|------|
|
||||
| Heute | LinkedIn (Company Page) | Post #1 (Marktdaten) |
|
||||
| Heute | 1–2 deutsche FB-Padel-Gruppen | Padel-Rechner |
|
||||
| Morgen | 1–2 FB-Tennisvereins-Gruppen | Tennisverein-Angle |
|
||||
| Tag 3 | LinkedIn (Company Page) | Post #2 (Standortanalyse) |
|
||||
| Woche 2 | LinkedIn (Company Page) | Post #3 (Gründerstory) |
|
||||
|
||||
---
|
||||
|
||||
## Regeln
|
||||
|
||||
- Ein Link pro Post, am Ende.
|
||||
- 24 Stunden auf jeden Kommentar reagieren.
|
||||
- Wenn ein Post Traktion bekommt: mit zusätzlichen Datenpunkten nachliefern.
|
||||
- UTM-Tracking: `?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marktdaten` bzw. `utm_source=facebook` für FB-Posts.
|
||||
248
docs/social-posts.md
Normal file
248
docs/social-posts.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Social Posts — Launch Day
|
||||
|
||||
> Ready to copy-paste. Domain: padelnomics.io
|
||||
> Created: 2026-03-04.
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn Post #1 — Data Insight
|
||||
|
||||
> Post type: data-driven thought leadership. Goal: establish credibility + drive traffic to planner.
|
||||
|
||||
```
|
||||
We've been tracking 10,127 padel facilities across 17 countries.
|
||||
|
||||
Here's what surprised me about the European market:
|
||||
|
||||
→ Italy leads with 3,069 facilities — more than Spain (2,241)
|
||||
→ Portugal has the world's most mature padel market (45.2/100 maturity score) with "only" 506 facilities
|
||||
→ Germany has just 359 facilities for 84M people. Spain has 2,241 for 47M.
|
||||
|
||||
That gap is the opportunity.
|
||||
|
||||
We identified 15,390 high-potential locations with zero padel courts worldwide.
|
||||
Hamburg, Munich, and Frankfurt top the list in Germany alone.
|
||||
|
||||
If you're thinking about opening a padel facility — or advising someone who is — we built a free ROI calculator that uses this data to model costs, revenue, and payback period for any city in Europe.
|
||||
|
||||
No signup required. Just real numbers.
|
||||
|
||||
→ https://padelnomics.io/en/planner/?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_marketdata
|
||||
|
||||
#padel #sportsbusiness #marketdata #entrepreneurship
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn Post #2 — Opportunity Angle (schedule for Day 2–3)
|
||||
|
||||
```
|
||||
The 5 most underserved cities for padel in Europe right now:
|
||||
|
||||
1. Hamburg (1.85M residents, zero dedicated padel facilities)
|
||||
2. Munich (1.26M residents, massive sports culture, minimal supply)
|
||||
3. Bergen, Norway (294K residents, opportunity score: 87.5/100)
|
||||
4. Graz, Austria (303K residents, zero courts, high income)
|
||||
5. Geneva, Switzerland (202K residents, zero courts, highest purchasing power)
|
||||
|
||||
These aren't guesses. We score 143,877 locations across Europe using population density, income data, existing supply, and sports infrastructure.
|
||||
|
||||
The padel market is growing from 25K to 50K+ facilities globally. The question isn't whether — it's where.
|
||||
|
||||
→ Explore the data for your city: https://padelnomics.io/en/markets?utm_source=linkedin&utm_medium=social&utm_campaign=launch&utm_content=li_opportunity
|
||||
|
||||
#padel #marketintelligence #sportsinvestment #realestate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reddit — r/padel
|
||||
|
||||
> Tone: genuinely helpful, not promotional. r/padel is a player community, so lead with the sport angle.
|
||||
|
||||
**Title:** I built a free padel court ROI calculator — feedback welcome
|
||||
|
||||
```
|
||||
Hey r/padel,
|
||||
|
||||
I've been working on a data project tracking the padel market across Europe
|
||||
(facility counts, market maturity, opportunity gaps). As part of that, I built
|
||||
a free calculator for anyone thinking about opening a padel facility.
|
||||
|
||||
It models:
|
||||
- CAPEX (construction, equipment, permits)
|
||||
- OPEX (rent, staffing, utilities, maintenance)
|
||||
- Revenue projections based on real market data from your city
|
||||
- 5-year P&L with payback period, IRR, and break-even
|
||||
|
||||
It pre-fills with city-specific defaults — so if you pick Munich, it uses
|
||||
Munich rents, Munich utility costs, etc. Not generic averages.
|
||||
|
||||
No signup needed. Just wanted to share in case anyone here has ever thought
|
||||
about the business side of padel.
|
||||
|
||||
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel
|
||||
|
||||
Happy to answer questions about the data or methodology. Also open to feedback
|
||||
on what would make this more useful.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reddit — r/entrepreneur
|
||||
|
||||
> Tone: indie builder sharing a project. r/entrepreneur loves "I built X" posts with real data.
|
||||
|
||||
**Title:** I'm building the "Bloomberg for padel" — tracking 10,127 facilities across 17 countries
|
||||
|
||||
```
|
||||
Padel is the fastest-growing sport in Europe and Latin America. There are now
|
||||
10,000+ facilities worldwide and the market is expected to double to 50K+ in
|
||||
the next 5 years.
|
||||
|
||||
The problem: anyone trying to open a padel facility is flying blind. No
|
||||
centralized market data exists. People are making €200K–€2M investment
|
||||
decisions based on Excel spreadsheets and gut feel.
|
||||
|
||||
I'm building Padelnomics — a data intelligence platform for the padel industry.
|
||||
Think "Kpler for padel" if you're familiar with commodity data platforms.
|
||||
|
||||
What's live right now:
|
||||
- Free ROI calculator that models costs, revenue, and payback for any European
|
||||
city (pre-filled with real local data — rents, utilities, permits, etc.)
|
||||
- 80 market analysis pages covering cities across 17 countries
|
||||
- Market maturity scoring for 4,686 cities with padel facilities
|
||||
- Opportunity scoring for 143,877 locations (identifying where to build next)
|
||||
|
||||
The data comes from OpenStreetMap, Playtomic (booking platform), Eurostat, and
|
||||
census data — aggregated and scored automatically.
|
||||
|
||||
Revenue model: free calculator captures leads (aspiring facility owners) →
|
||||
supplier directory connects them with builders → suppliers pay for qualified
|
||||
leads via credit system.
|
||||
|
||||
Still early but the data moat compounds daily — every day of scraping = data
|
||||
competitors can't replicate.
|
||||
|
||||
Would love feedback from anyone who's built data products or two-sided
|
||||
marketplaces.
|
||||
|
||||
→ https://padelnomics.io/en/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_entrepreneur
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reddit — r/smallbusiness
|
||||
|
||||
> Tone: practical tool for a real business decision.
|
||||
|
||||
**Title:** Free business planning tool for anyone looking at opening a sports facility
|
||||
|
||||
```
|
||||
I built a free financial planning tool specifically for padel facilities
|
||||
(indoor/outdoor sports courts — fastest growing sport in Europe right now).
|
||||
|
||||
It covers the full picture:
|
||||
- Construction costs (indoor vs outdoor, number of courts)
|
||||
- Operating expenses (rent, staff, utilities, insurance, maintenance)
|
||||
- Revenue modeling (hourly rates, occupancy rates, lessons, events)
|
||||
- 5-year P&L projection
|
||||
- Key metrics: payback period, IRR, break-even point
|
||||
|
||||
The tool pre-fills with real data for your city — actual local rents, utility
|
||||
costs, permit fees — not generic averages.
|
||||
|
||||
You can also generate a bank-ready business plan PDF from it.
|
||||
|
||||
Free to use, no signup required for the calculator itself.
|
||||
|
||||
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_smallbusiness
|
||||
|
||||
Built this because I kept seeing people on forums asking "how much does it cost
|
||||
to open a padel hall?" and getting wildly different answers. Figured real data
|
||||
was better than guesswork.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reddit — r/tennis
|
||||
|
||||
> Tone: cross-sport angle. Many tennis clubs are adding padel courts.
|
||||
|
||||
**Title:** Data on padel facility economics — useful for tennis clubs considering adding courts
|
||||
|
||||
```
|
||||
If your club is thinking about adding padel courts (and many are right now),
|
||||
I built a free financial planning tool that models the full economics:
|
||||
|
||||
- CAPEX for adding 2–6 courts to an existing facility
|
||||
- Revenue projections based on real occupancy and pricing data
|
||||
- Operating costs specific to your city/country
|
||||
- Payback period and ROI metrics
|
||||
|
||||
The tool uses actual market data — we track 10,127 padel facilities across
|
||||
17 countries and score market maturity + opportunity by city.
|
||||
|
||||
Some interesting numbers:
|
||||
- Average padel facility in a mature market runs at 60–75% occupancy
|
||||
- A 4-court outdoor setup costs €200K–€350K
|
||||
- Indoor builds jump to €700K–€3M depending on structure
|
||||
- Tennis clubs converting 2 courts to padel typically see payback in 18–30 months
|
||||
|
||||
Free to use, no signup needed.
|
||||
|
||||
→ https://padelnomics.io/en/planner/?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_tennis
|
||||
|
||||
Happy to share data on any specific city or country if you're evaluating this
|
||||
for your club.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Facebook Groups — Padel Business / Deutschland
|
||||
|
||||
> Tone: casual, helpful. Shorter than Reddit posts.
|
||||
|
||||
**Title (if group allows):** Free padel facility ROI calculator — uses real market data
|
||||
|
||||
```
|
||||
Hey everyone 👋
|
||||
|
||||
Built a free tool for anyone planning a padel facility. It models CAPEX,
|
||||
OPEX, revenue, and gives you a 5-year P&L with payback period.
|
||||
|
||||
The difference from spreadsheet templates: it pre-fills with real data for
|
||||
your city (actual rents, utility costs, permit fees, etc.) based on data
|
||||
we're collecting across 17 countries.
|
||||
|
||||
No signup, no cost. Just real numbers.
|
||||
|
||||
→ https://padelnomics.io/en/planner/?utm_source=facebook&utm_medium=social&utm_campaign=launch&utm_content=fb_padel
|
||||
|
||||
Feedback welcome — especially from anyone who's been through the planning
|
||||
process and knows what numbers actually matter.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Posting Schedule
|
||||
|
||||
| Day | Platform | Post |
|
||||
|-----|----------|------|
|
||||
| Today | LinkedIn | Post #1 (Data Insight) |
|
||||
| Today | r/padel | Calculator feedback post |
|
||||
| Today | r/entrepreneur | "Bloomberg for padel" builder post |
|
||||
| Today | 1–2 FB groups | Calculator share |
|
||||
| Tomorrow | r/smallbusiness | Business planning tool post |
|
||||
| Tomorrow | r/tennis | Tennis club angle |
|
||||
| Day 3 | LinkedIn | Post #2 (Opportunity Angle) |
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- Never link-spam. One link per post, at the end.
|
||||
- Engage with every comment for 24 hours after posting.
|
||||
- If a post gets traction, reply with additional data points to keep it alive.
|
||||
- Track which subreddits/groups drive actual signups via UTM params:
|
||||
`?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_content=r_padel`
|
||||
@@ -21,6 +21,7 @@ extract-census-usa = "padelnomics_extract.census_usa:main"
|
||||
extract-census-usa-income = "padelnomics_extract.census_usa_income:main"
|
||||
extract-ons-uk = "padelnomics_extract.ons_uk:main"
|
||||
extract-geonames = "padelnomics_extract.geonames:main"
|
||||
extract-gisco = "padelnomics_extract.gisco:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -11,9 +11,12 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import niquests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .utils import end_run, open_state_db, start_run
|
||||
|
||||
load_dotenv()
|
||||
|
||||
LANDING_DIR = Path(os.environ.get("LANDING_DIR", "data/landing"))
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = 30
|
||||
|
||||
@@ -7,7 +7,7 @@ A graphlib.TopologicalSorter schedules them: tasks with no unmet dependencies
|
||||
run immediately in parallel; each completion may unlock new tasks.
|
||||
|
||||
Current dependency graph:
|
||||
- All 8 non-availability extractors have no dependencies (run in parallel)
|
||||
- All 9 non-availability extractors have no dependencies (run in parallel)
|
||||
- playtomic_availability depends on playtomic_tenants (starts as soon as
|
||||
tenants finishes, even if other extractors are still running)
|
||||
"""
|
||||
@@ -26,6 +26,8 @@ from .eurostat_city_labels import EXTRACTOR_NAME as EUROSTAT_CITY_LABELS_NAME
|
||||
from .eurostat_city_labels import extract as extract_eurostat_city_labels
|
||||
from .geonames import EXTRACTOR_NAME as GEONAMES_NAME
|
||||
from .geonames import extract as extract_geonames
|
||||
from .gisco import EXTRACTOR_NAME as GISCO_NAME
|
||||
from .gisco import extract as extract_gisco
|
||||
from .ons_uk import EXTRACTOR_NAME as ONS_UK_NAME
|
||||
from .ons_uk import extract as extract_ons_uk
|
||||
from .overpass import EXTRACTOR_NAME as OVERPASS_NAME
|
||||
@@ -50,6 +52,7 @@ EXTRACTORS: dict[str, tuple] = {
|
||||
CENSUS_USA_INCOME_NAME: (extract_census_usa_income, []),
|
||||
ONS_UK_NAME: (extract_ons_uk, []),
|
||||
GEONAMES_NAME: (extract_geonames, []),
|
||||
GISCO_NAME: (extract_gisco, []),
|
||||
TENANTS_NAME: (extract_tenants, []),
|
||||
AVAILABILITY_NAME: (extract_availability, [TENANTS_NAME]),
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
import niquests
|
||||
|
||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
|
||||
from .utils import get_last_cursor, landing_path, write_gzip_atomic
|
||||
from .utils import landing_path, skip_if_current, write_gzip_atomic
|
||||
|
||||
logger = setup_logging("padelnomics.extract.census_usa")
|
||||
|
||||
@@ -73,10 +73,10 @@ def extract(
|
||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||
|
||||
# Skip if we already have data for this month (annual data, monthly cursor)
|
||||
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
|
||||
if last_cursor == year_month:
|
||||
skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
|
||||
if skip:
|
||||
logger.info("already have data for %s — skipping", year_month)
|
||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||
return skip
|
||||
|
||||
year, month = year_month.split("/")
|
||||
url = f"{ACS_URL}&key={api_key}"
|
||||
|
||||
@@ -26,6 +26,10 @@ EUROSTAT_BASE_URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/
|
||||
|
||||
# Dataset configs: filters fix dimension values, geo_dim/time_dim are iterated.
|
||||
# All other dimensions must either be in filters or have size=1.
|
||||
#
|
||||
# Optional `dataset_code` field: when present, used for the API URL instead of the dict key.
|
||||
# This allows multiple entries to share the same Eurostat dataset with different filters
|
||||
# (e.g. five prc_ppp_ind entries with different ppp_cat values).
|
||||
DATASETS: dict[str, dict] = {
|
||||
"urb_cpop1": {
|
||||
"filters": {"indic_ur": "DE1001V"}, # Population on 1 January, total
|
||||
@@ -51,6 +55,59 @@ DATASETS: dict[str, dict] = {
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
# ── Direct-value datasets (actual EUR figures) ───────────────────────────
|
||||
"nrg_pc_205": {
|
||||
# Electricity prices for non-household consumers, EUR/kWh, excl. taxes
|
||||
"filters": {"freq": "S", "nrg_cons": "MWH500-1999", "currency": "EUR", "tax": "I_TAX"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"nrg_pc_203": {
|
||||
# Gas prices for non-household consumers, EUR/kWh, excl. taxes
|
||||
"filters": {"freq": "S", "nrg_cons": "GJ1000-9999", "unit": "KWH", "currency": "EUR", "tax": "I_TAX"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"lc_lci_lev": {
|
||||
# Labour cost levels EUR/hour — NACE N (administrative/support services)
|
||||
# D1_D4_MD5 = compensation of employees + taxes - subsidies (total labour cost)
|
||||
"filters": {"lcstruct": "D1_D4_MD5", "nace_r2": "N", "unit": "EUR"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
# ── Price level indices (relative scaling, EU27=100) ─────────────────────
|
||||
# Five entries share the prc_ppp_ind dataset with different ppp_cat filters.
|
||||
# dataset_code points to the real API endpoint; the dict key is the landing filename.
|
||||
"prc_ppp_ind_construction": {
|
||||
"dataset_code": "prc_ppp_ind",
|
||||
"filters": {"ppp_cat": "A050202", "na_item": "PLI_EU27_2020"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"prc_ppp_ind_housing": {
|
||||
"dataset_code": "prc_ppp_ind",
|
||||
"filters": {"ppp_cat": "A0104", "na_item": "PLI_EU27_2020"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"prc_ppp_ind_services": {
|
||||
"dataset_code": "prc_ppp_ind",
|
||||
"filters": {"ppp_cat": "P0201", "na_item": "PLI_EU27_2020"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"prc_ppp_ind_misc": {
|
||||
"dataset_code": "prc_ppp_ind",
|
||||
"filters": {"ppp_cat": "A0112", "na_item": "PLI_EU27_2020"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
"prc_ppp_ind_government": {
|
||||
"dataset_code": "prc_ppp_ind",
|
||||
"filters": {"ppp_cat": "P0202", "na_item": "PLI_EU27_2020"},
|
||||
"geo_dim": "geo",
|
||||
"time_dim": "time",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -196,22 +253,25 @@ def extract(
|
||||
files_skipped = 0
|
||||
bytes_written_total = 0
|
||||
|
||||
for dataset_code, config in DATASETS.items():
|
||||
url = f"{EUROSTAT_BASE_URL}/{dataset_code}?format=JSON&lang=EN"
|
||||
for dataset_key, config in DATASETS.items():
|
||||
# Use dataset_code (if set) for the API URL; fall back to the dict key.
|
||||
# This lets multiple entries share one Eurostat dataset with different filters.
|
||||
api_code = config.get("dataset_code", dataset_key)
|
||||
url = f"{EUROSTAT_BASE_URL}/{api_code}?format=JSON&lang=EN"
|
||||
for key, val in config.get("filters", {}).items():
|
||||
url += f"&{key}={val}"
|
||||
dest_dir = landing_path(landing_dir, "eurostat", year, month)
|
||||
dest = dest_dir / f"{dataset_code}.json.gz"
|
||||
dest = dest_dir / f"{dataset_key}.json.gz"
|
||||
|
||||
logger.info("GET %s", dataset_code)
|
||||
logger.info("GET %s", dataset_key)
|
||||
bytes_written = _fetch_with_etag(url, dest, session, config)
|
||||
|
||||
if bytes_written > 0:
|
||||
logger.info("%s updated — %s bytes compressed", dataset_code, f"{bytes_written:,}")
|
||||
logger.info("%s updated — %s bytes compressed", dataset_key, f"{bytes_written:,}")
|
||||
files_written += 1
|
||||
bytes_written_total += bytes_written
|
||||
else:
|
||||
logger.info("%s not modified (304)", dataset_code)
|
||||
logger.info("%s not modified (304)", dataset_key)
|
||||
files_skipped += 1
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,7 +19,6 @@ Output: one JSON object per line, e.g.:
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import zipfile
|
||||
@@ -28,7 +27,7 @@ from pathlib import Path
|
||||
import niquests
|
||||
|
||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
|
||||
from .utils import compress_jsonl_atomic, get_last_cursor, landing_path
|
||||
from .utils import landing_path, skip_if_current, write_jsonl_atomic
|
||||
|
||||
logger = setup_logging("padelnomics.extract.geonames")
|
||||
|
||||
@@ -139,10 +138,10 @@ def extract(
|
||||
tmp.rename(dest)
|
||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||
|
||||
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
|
||||
if last_cursor == year_month:
|
||||
skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
|
||||
if skip:
|
||||
logger.info("already have data for %s — skipping", year_month)
|
||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||
return skip
|
||||
|
||||
year, month = year_month.split("/")
|
||||
|
||||
@@ -168,11 +167,7 @@ def extract(
|
||||
|
||||
dest_dir = landing_path(landing_dir, "geonames", year, month)
|
||||
dest = dest_dir / "cities_global.jsonl.gz"
|
||||
working_path = dest.with_suffix(".working.jsonl")
|
||||
with open(working_path, "w") as f:
|
||||
for row in rows:
|
||||
f.write(json.dumps(row, separators=(",", ":")) + "\n")
|
||||
bytes_written = compress_jsonl_atomic(working_path, dest)
|
||||
bytes_written = write_jsonl_atomic(dest, rows)
|
||||
logger.info("written %s bytes compressed", f"{bytes_written:,}")
|
||||
|
||||
return {
|
||||
|
||||
95
extract/padelnomics_extract/src/padelnomics_extract/gisco.py
Normal file
95
extract/padelnomics_extract/src/padelnomics_extract/gisco.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""GISCO NUTS-2 boundary GeoJSON extractor.
|
||||
|
||||
Downloads NUTS-2 boundary polygons from Eurostat GISCO. The file is stored
|
||||
uncompressed because DuckDB's ST_Read cannot read gzipped files.
|
||||
|
||||
NUTS classification revises approximately every 7 years (current: 2021).
|
||||
The partition path is fixed to the revision year, not the run date, making
|
||||
the source version explicit. Cursor tracking still uses year_month to avoid
|
||||
re-downloading on every monthly run.
|
||||
|
||||
Landing: {LANDING_DIR}/gisco/2024/01/nuts2_boundaries.geojson (~5 MB, uncompressed)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import niquests
|
||||
|
||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
|
||||
from .utils import skip_if_current
|
||||
|
||||
logger = setup_logging("padelnomics.extract.gisco")
|
||||
|
||||
EXTRACTOR_NAME = "gisco"
|
||||
|
||||
# NUTS 2021 revision, 20M scale (1:20,000,000), WGS84 (EPSG:4326), LEVL_2 only.
|
||||
# 20M resolution gives simplified polygons that are fast for point-in-polygon
|
||||
# matching without sacrificing accuracy at the NUTS-2 boundary level.
|
||||
GISCO_URL = (
|
||||
"https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
|
||||
"NUTS_RG_20M_2021_4326_LEVL_2.geojson"
|
||||
)
|
||||
|
||||
# Fixed partition: NUTS boundaries are a static reference file, not time-series data.
|
||||
# The 2024/01 partition reflects when this NUTS 2021 dataset was first ingested.
|
||||
DEST_REL = Path("gisco/2024/01/nuts2_boundaries.geojson")
|
||||
|
||||
_GISCO_TIMEOUT_SECONDS = HTTP_TIMEOUT_SECONDS * 4 # ~5 MB; generous for slow upstreams
|
||||
|
||||
|
||||
def extract(
|
||||
landing_dir: Path,
|
||||
year_month: str,
|
||||
conn: sqlite3.Connection,
|
||||
session: niquests.Session,
|
||||
) -> dict:
|
||||
"""Download NUTS-2 GeoJSON. Skips if already run this month or file exists."""
|
||||
skip = skip_if_current(conn, EXTRACTOR_NAME, year_month)
|
||||
if skip:
|
||||
logger.info("already ran for %s — skipping", year_month)
|
||||
return skip
|
||||
|
||||
dest = landing_dir / DEST_REL
|
||||
if dest.exists():
|
||||
logger.info("file already exists (skipping download): %s", dest)
|
||||
return {
|
||||
"files_written": 0,
|
||||
"files_skipped": 1,
|
||||
"bytes_written": 0,
|
||||
"cursor_value": year_month,
|
||||
}
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("GET %s", GISCO_URL)
|
||||
resp = session.get(GISCO_URL, timeout=_GISCO_TIMEOUT_SECONDS)
|
||||
resp.raise_for_status()
|
||||
|
||||
content = resp.content
|
||||
assert len(content) > 100_000, (
|
||||
f"GeoJSON too small ({len(content)} bytes) — download may have failed"
|
||||
)
|
||||
assert b'"FeatureCollection"' in content, "Response does not look like GeoJSON"
|
||||
|
||||
# Write uncompressed — ST_Read requires a plain file, not .gz
|
||||
tmp = dest.with_suffix(".geojson.tmp")
|
||||
tmp.write_bytes(content)
|
||||
tmp.rename(dest)
|
||||
|
||||
size_mb = len(content) / 1_000_000
|
||||
logger.info("written %s (%.1f MB)", dest, size_mb)
|
||||
|
||||
return {
|
||||
"files_written": 1,
|
||||
"files_skipped": 0,
|
||||
"bytes_written": len(content),
|
||||
"cursor_value": year_month,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run_extractor(EXTRACTOR_NAME, extract)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,7 +33,7 @@ from pathlib import Path
|
||||
import niquests
|
||||
|
||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy
|
||||
from .proxy import load_fallback_proxy_urls, load_proxy_urls, make_tiered_cycler
|
||||
from .proxy import load_proxy_tiers, make_tiered_cycler
|
||||
from .utils import (
|
||||
compress_jsonl_atomic,
|
||||
flush_partial_batch,
|
||||
@@ -52,6 +52,9 @@ MAX_VENUES_PER_RUN = 20_000
|
||||
MAX_RETRIES_PER_VENUE = 2
|
||||
RECHECK_WINDOW_MINUTES = int(os.environ.get("RECHECK_WINDOW_MINUTES", "30"))
|
||||
CIRCUIT_BREAKER_THRESHOLD = int(os.environ.get("CIRCUIT_BREAKER_THRESHOLD") or "10")
|
||||
# Worker count: defaults to MAX_PROXY_CONCURRENCY (200). Override via PROXY_CONCURRENCY env var.
|
||||
_PROXY_CONCURRENCY = os.environ.get("PROXY_CONCURRENCY", "").strip()
|
||||
MAX_PROXY_CONCURRENCY = 200
|
||||
|
||||
# Parallel mode submits futures in batches so the circuit breaker can stop
|
||||
# new submissions after it opens. Already-inflight futures in the current
|
||||
@@ -76,8 +79,10 @@ def _load_tenant_ids(landing_dir: Path) -> list[str]:
|
||||
if not playtomic_dir.exists():
|
||||
return []
|
||||
|
||||
# Prefer JSONL (new format), fall back to blob (old format)
|
||||
tenant_files = sorted(playtomic_dir.glob("*/*/tenants.jsonl.gz"), reverse=True)
|
||||
# Prefer daily partition (YYYY/MM/DD), fall back to older monthly/weekly partitions
|
||||
tenant_files = sorted(playtomic_dir.glob("*/*/*/tenants.jsonl.gz"), reverse=True)
|
||||
if not tenant_files:
|
||||
tenant_files = sorted(playtomic_dir.glob("*/*/tenants.jsonl.gz"), reverse=True)
|
||||
if not tenant_files:
|
||||
tenant_files = sorted(playtomic_dir.glob("*/*/tenants.json.gz"), reverse=True)
|
||||
if not tenant_files:
|
||||
@@ -190,14 +195,13 @@ def _fetch_venues_parallel(
|
||||
start_max_str: str,
|
||||
worker_count: int,
|
||||
cycler: dict,
|
||||
fallback_urls: list[str],
|
||||
on_result=None,
|
||||
) -> 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.
|
||||
completes, checks the circuit breaker: if all proxy tiers are exhausted,
|
||||
stops submitting further batches.
|
||||
|
||||
on_result: optional callable(result: dict) invoked inside the lock for
|
||||
each successful result — used for incremental partial-file flushing.
|
||||
@@ -209,16 +213,17 @@ def _fetch_venues_parallel(
|
||||
completed_count = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def _worker(tenant_id: str) -> dict | None:
|
||||
def _worker(tenant_id: str) -> tuple[str | None, dict | None]:
|
||||
proxy_url = cycler["next_proxy"]()
|
||||
return _fetch_venue_availability(tenant_id, start_min_str, start_max_str, proxy_url)
|
||||
result = _fetch_venue_availability(tenant_id, start_min_str, start_max_str, proxy_url)
|
||||
return proxy_url, result
|
||||
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as pool:
|
||||
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:
|
||||
# Stop submitting new work if all proxy tiers are exhausted
|
||||
if cycler["is_exhausted"]():
|
||||
logger.error(
|
||||
"Circuit open with no fallback — stopping after %d/%d venues",
|
||||
"All proxy tiers exhausted — stopping after %d/%d venues",
|
||||
completed_count, len(tenant_ids),
|
||||
)
|
||||
break
|
||||
@@ -227,17 +232,17 @@ def _fetch_venues_parallel(
|
||||
batch_futures = {pool.submit(_worker, tid): tid for tid in batch}
|
||||
|
||||
for future in as_completed(batch_futures):
|
||||
result = future.result()
|
||||
proxy_url, result = future.result()
|
||||
with lock:
|
||||
completed_count += 1
|
||||
if result is not None:
|
||||
venues_data.append(result)
|
||||
cycler["record_success"]()
|
||||
cycler["record_success"](proxy_url)
|
||||
if on_result is not None:
|
||||
on_result(result)
|
||||
else:
|
||||
venues_errored += 1
|
||||
cycler["record_failure"]()
|
||||
cycler["record_failure"](proxy_url)
|
||||
|
||||
if completed_count % 500 == 0:
|
||||
logger.info(
|
||||
@@ -294,10 +299,9 @@ def extract(
|
||||
venues_to_process = [tid for tid in all_venues_to_process if tid not in already_done]
|
||||
|
||||
# Set up tiered proxy cycler with circuit breaker
|
||||
proxy_urls = load_proxy_urls()
|
||||
fallback_urls = load_fallback_proxy_urls()
|
||||
worker_count = len(proxy_urls) if proxy_urls else 1
|
||||
cycler = make_tiered_cycler(proxy_urls, fallback_urls, CIRCUIT_BREAKER_THRESHOLD)
|
||||
tiers = load_proxy_tiers()
|
||||
worker_count = min(int(_PROXY_CONCURRENCY), MAX_PROXY_CONCURRENCY) if _PROXY_CONCURRENCY else (MAX_PROXY_CONCURRENCY if tiers else 1)
|
||||
cycler = make_tiered_cycler(tiers, 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")
|
||||
@@ -325,26 +329,27 @@ def extract(
|
||||
venues_errored = 0
|
||||
|
||||
if worker_count > 1:
|
||||
logger.info("Parallel mode: %d workers, %d proxies", worker_count, len(proxy_urls))
|
||||
logger.info("Parallel mode: %d workers, %d tier(s)", worker_count, len(tiers))
|
||||
new_venues_data, venues_errored = _fetch_venues_parallel(
|
||||
venues_to_process, start_min_str, start_max_str, worker_count, cycler, fallback_urls,
|
||||
venues_to_process, start_min_str, start_max_str, worker_count, cycler,
|
||||
on_result=_on_result,
|
||||
)
|
||||
else:
|
||||
logger.info("Serial mode: 1 worker, %d venues", len(venues_to_process))
|
||||
for i, tenant_id in enumerate(venues_to_process):
|
||||
proxy_url = cycler["next_proxy"]()
|
||||
result = _fetch_venue_availability(
|
||||
tenant_id, start_min_str, start_max_str, cycler["next_proxy"](),
|
||||
tenant_id, start_min_str, start_max_str, proxy_url,
|
||||
)
|
||||
if result is not None:
|
||||
new_venues_data.append(result)
|
||||
cycler["record_success"]()
|
||||
cycler["record_success"](proxy_url)
|
||||
_on_result(result)
|
||||
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")
|
||||
cycler["record_failure"](proxy_url)
|
||||
if cycler["is_exhausted"]():
|
||||
logger.error("All proxy tiers exhausted — writing partial results")
|
||||
break
|
||||
|
||||
if (i + 1) % 100 == 0:
|
||||
@@ -429,8 +434,10 @@ def _find_venues_with_upcoming_slots(
|
||||
if not start_time_str:
|
||||
continue
|
||||
try:
|
||||
# Parse "2026-02-24T17:00:00" format
|
||||
slot_start = datetime.fromisoformat(start_time_str).replace(tzinfo=UTC)
|
||||
# start_time is "HH:MM:SS"; combine with resource's start_date
|
||||
start_date = resource.get("start_date", "")
|
||||
full_dt = f"{start_date}T{start_time_str}" if start_date else start_time_str
|
||||
slot_start = datetime.fromisoformat(full_dt).replace(tzinfo=UTC)
|
||||
if window_start <= slot_start < window_end:
|
||||
tenant_ids.add(tid)
|
||||
break # found one upcoming slot, no need to check more
|
||||
@@ -485,28 +492,28 @@ def extract_recheck(
|
||||
start_max_str = window_end.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# Set up tiered proxy cycler with circuit breaker
|
||||
proxy_urls = load_proxy_urls()
|
||||
fallback_urls = load_fallback_proxy_urls()
|
||||
worker_count = len(proxy_urls) if proxy_urls else 1
|
||||
cycler = make_tiered_cycler(proxy_urls, fallback_urls, CIRCUIT_BREAKER_THRESHOLD)
|
||||
tiers = load_proxy_tiers()
|
||||
worker_count = min(int(_PROXY_CONCURRENCY), MAX_PROXY_CONCURRENCY) if _PROXY_CONCURRENCY else (MAX_PROXY_CONCURRENCY if tiers else 1)
|
||||
cycler = make_tiered_cycler(tiers, 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, cycler, fallback_urls,
|
||||
venues_to_recheck, start_min_str, start_max_str, worker_count, cycler,
|
||||
)
|
||||
else:
|
||||
venues_data = []
|
||||
venues_errored = 0
|
||||
for tid in venues_to_recheck:
|
||||
result = _fetch_venue_availability(tid, start_min_str, start_max_str, cycler["next_proxy"]())
|
||||
proxy_url = cycler["next_proxy"]()
|
||||
result = _fetch_venue_availability(tid, start_min_str, start_max_str, proxy_url)
|
||||
if result is not None:
|
||||
venues_data.append(result)
|
||||
cycler["record_success"]()
|
||||
cycler["record_success"](proxy_url)
|
||||
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")
|
||||
cycler["record_failure"](proxy_url)
|
||||
if cycler["is_exhausted"]():
|
||||
logger.error("All proxy tiers exhausted — writing partial recheck results")
|
||||
break
|
||||
|
||||
# Write recheck file as JSONL — one venue per line with metadata injected
|
||||
@@ -515,6 +522,10 @@ def extract_recheck(
|
||||
dest_dir = landing_path(landing_dir, "playtomic", year, month)
|
||||
dest = dest_dir / f"availability_{target_date}_recheck_{recheck_hour:02d}.jsonl.gz"
|
||||
|
||||
if not venues_data:
|
||||
logger.warning("Recheck fetched 0 venues (%d errors) — skipping file write", venues_errored)
|
||||
return {"files_written": 0, "files_skipped": 0, "bytes_written": 0}
|
||||
|
||||
captured_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
working_path = dest.with_suffix("").with_suffix(".working.jsonl")
|
||||
with open(working_path, "w") as f:
|
||||
|
||||
@@ -10,28 +10,29 @@ API notes (discovered 2026-02):
|
||||
- `size=100` is the maximum effective page size
|
||||
- ~14K venues globally as of Feb 2026
|
||||
|
||||
Parallel mode: when PROXY_URLS is set, fires batch_size = len(proxy_urls)
|
||||
pages concurrently. Each page gets its own fresh session + proxy. Pages beyond
|
||||
the last one return empty lists (safe — just triggers the done condition).
|
||||
Without proxies, falls back to single-threaded with THROTTLE_SECONDS between
|
||||
pages.
|
||||
Parallel mode: when proxy tiers are configured, fires BATCH_SIZE pages
|
||||
concurrently. Each page gets its own fresh session + proxy from the tiered
|
||||
cycler. On failure the cycler escalates through free → datacenter →
|
||||
residential tiers. Without proxies, falls back to single-threaded with
|
||||
THROTTLE_SECONDS between pages.
|
||||
|
||||
Rate: 1 req / 2 s per IP (see docs/data-sources-inventory.md §1.2).
|
||||
|
||||
Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.jsonl.gz
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import niquests
|
||||
|
||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy
|
||||
from .proxy import load_proxy_urls, make_round_robin_cycler
|
||||
from .utils import compress_jsonl_atomic, landing_path
|
||||
from .proxy import load_proxy_tiers, make_tiered_cycler
|
||||
from .utils import landing_path, write_jsonl_atomic
|
||||
|
||||
logger = setup_logging("padelnomics.extract.playtomic_tenants")
|
||||
|
||||
@@ -41,6 +42,9 @@ PLAYTOMIC_TENANTS_URL = "https://api.playtomic.io/v1/tenants"
|
||||
THROTTLE_SECONDS = 2
|
||||
PAGE_SIZE = 100
|
||||
MAX_PAGES = 500 # safety bound — ~50K venues max, well above current ~14K
|
||||
BATCH_SIZE = 20 # concurrent pages per batch (fixed, independent of proxy count)
|
||||
CIRCUIT_BREAKER_THRESHOLD = int(os.environ.get("CIRCUIT_BREAKER_THRESHOLD") or "10")
|
||||
MAX_PAGE_ATTEMPTS = 5 # max retries per individual page before giving up
|
||||
|
||||
|
||||
def _fetch_one_page(proxy_url: str | None, page: int) -> tuple[int, list[dict]]:
|
||||
@@ -60,34 +64,79 @@ def _fetch_one_page(proxy_url: str | None, page: int) -> tuple[int, list[dict]]:
|
||||
return (page, tenants)
|
||||
|
||||
|
||||
def _fetch_pages_parallel(pages: list[int], next_proxy) -> list[tuple[int, list[dict]]]:
|
||||
"""Fetch multiple pages concurrently. Returns [(page_num, tenants_list), ...]."""
|
||||
def _fetch_page_via_cycler(cycler: dict, page: int) -> tuple[int, list[dict]]:
|
||||
"""Fetch a single page, retrying across proxy tiers via the circuit breaker.
|
||||
|
||||
On each attempt, pulls the next proxy from the active tier. Records
|
||||
success/failure so the circuit breaker can escalate tiers. Raises
|
||||
RuntimeError if all tiers are exhausted or MAX_PAGE_ATTEMPTS is exceeded.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_PAGE_ATTEMPTS):
|
||||
proxy_url = cycler["next_proxy"]()
|
||||
if proxy_url is None: # all tiers exhausted
|
||||
raise RuntimeError(f"All proxy tiers exhausted fetching page {page}")
|
||||
try:
|
||||
result = _fetch_one_page(proxy_url, page)
|
||||
cycler["record_success"](proxy_url)
|
||||
return result
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"Page %d attempt %d/%d failed (proxy=%s): %s",
|
||||
page,
|
||||
attempt + 1,
|
||||
MAX_PAGE_ATTEMPTS,
|
||||
proxy_url,
|
||||
exc,
|
||||
)
|
||||
cycler["record_failure"](proxy_url)
|
||||
if cycler["is_exhausted"]():
|
||||
raise RuntimeError(f"All proxy tiers exhausted fetching page {page}") from exc
|
||||
raise RuntimeError(f"Page {page} failed after {MAX_PAGE_ATTEMPTS} attempts") from last_exc
|
||||
|
||||
|
||||
def _fetch_pages_parallel(pages: list[int], cycler: dict) -> list[tuple[int, list[dict]]]:
|
||||
"""Fetch multiple pages concurrently using the tiered cycler.
|
||||
|
||||
Returns [(page_num, tenants_list), ...]. Raises if any page exhausts all tiers.
|
||||
"""
|
||||
with ThreadPoolExecutor(max_workers=len(pages)) as pool:
|
||||
futures = [pool.submit(_fetch_one_page, next_proxy(), p) for p in pages]
|
||||
futures = [pool.submit(_fetch_page_via_cycler, cycler, p) for p in pages]
|
||||
return [f.result() for f in as_completed(futures)]
|
||||
|
||||
|
||||
def extract(
|
||||
landing_dir: Path,
|
||||
year_month: str,
|
||||
year_month: str, # noqa: ARG001 — unused; tenants uses daily partition instead
|
||||
conn: sqlite3.Connection,
|
||||
session: niquests.Session,
|
||||
) -> dict:
|
||||
"""Fetch all Playtomic venues via global pagination. Returns run metrics."""
|
||||
year, month = year_month.split("/")
|
||||
dest_dir = landing_path(landing_dir, "playtomic", year, month)
|
||||
"""Fetch all Playtomic venues via global pagination. Returns run metrics.
|
||||
|
||||
Partitioned by day (e.g. 2026/03/01) so each daily run produces a
|
||||
fresh file. _load_tenant_ids() in playtomic_availability globs across all
|
||||
partitions and picks the most recent one.
|
||||
"""
|
||||
today = datetime.now(UTC)
|
||||
year, month, day = today.strftime("%Y"), today.strftime("%m"), today.strftime("%d")
|
||||
dest_dir = landing_path(landing_dir, "playtomic", year, month, day)
|
||||
dest = dest_dir / "tenants.jsonl.gz"
|
||||
old_blob = dest_dir / "tenants.json.gz"
|
||||
if dest.exists() or old_blob.exists():
|
||||
logger.info("Already have tenants for %s/%s — skipping", year, month)
|
||||
if dest.exists():
|
||||
logger.info("Already have tenants for %s/%s/%s — skipping", year, month, day)
|
||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||
|
||||
proxy_urls = load_proxy_urls()
|
||||
next_proxy = make_round_robin_cycler(proxy_urls) if proxy_urls else None
|
||||
batch_size = len(proxy_urls) if proxy_urls else 1
|
||||
tiers = load_proxy_tiers()
|
||||
cycler = make_tiered_cycler(tiers, CIRCUIT_BREAKER_THRESHOLD) if tiers else None
|
||||
batch_size = BATCH_SIZE if cycler else 1
|
||||
|
||||
if next_proxy:
|
||||
logger.info("Parallel mode: %d pages per batch (%d proxies)", batch_size, len(proxy_urls))
|
||||
if cycler:
|
||||
logger.info(
|
||||
"Parallel mode: %d pages/batch, %d tier(s), threshold=%d",
|
||||
batch_size,
|
||||
cycler["tier_count"](),
|
||||
CIRCUIT_BREAKER_THRESHOLD,
|
||||
)
|
||||
else:
|
||||
logger.info("Serial mode: 1 page at a time (no proxies)")
|
||||
|
||||
@@ -97,15 +146,33 @@ def extract(
|
||||
done = False
|
||||
|
||||
while not done and page < MAX_PAGES:
|
||||
if cycler and cycler["is_exhausted"]():
|
||||
logger.error(
|
||||
"All proxy tiers exhausted — stopping at page %d (%d venues collected)",
|
||||
page,
|
||||
len(all_tenants),
|
||||
)
|
||||
break
|
||||
|
||||
batch_end = min(page + batch_size, MAX_PAGES)
|
||||
pages_to_fetch = list(range(page, batch_end))
|
||||
|
||||
if next_proxy and len(pages_to_fetch) > 1:
|
||||
if cycler and len(pages_to_fetch) > 1:
|
||||
logger.info(
|
||||
"Fetching pages %d-%d in parallel (%d workers, total so far: %d)",
|
||||
page, batch_end - 1, len(pages_to_fetch), len(all_tenants),
|
||||
page,
|
||||
batch_end - 1,
|
||||
len(pages_to_fetch),
|
||||
len(all_tenants),
|
||||
)
|
||||
results = _fetch_pages_parallel(pages_to_fetch, next_proxy)
|
||||
try:
|
||||
results = _fetch_pages_parallel(pages_to_fetch, cycler)
|
||||
except RuntimeError:
|
||||
logger.error(
|
||||
"Proxy tiers exhausted mid-batch — writing partial results (%d venues)",
|
||||
len(all_tenants),
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Serial: reuse the shared session, throttle between pages
|
||||
page_num = pages_to_fetch[0]
|
||||
@@ -119,7 +186,7 @@ def extract(
|
||||
)
|
||||
results = [(page_num, tenants)]
|
||||
|
||||
# Process pages in order so the done-detection on < PAGE_SIZE is deterministic
|
||||
# Process pages in order so done-detection on < PAGE_SIZE is deterministic
|
||||
for p, tenants in sorted(results):
|
||||
new_count = 0
|
||||
for tenant in tenants:
|
||||
@@ -130,7 +197,11 @@ def extract(
|
||||
new_count += 1
|
||||
|
||||
logger.info(
|
||||
"page=%d got=%d new=%d total=%d", p, len(tenants), new_count, len(all_tenants),
|
||||
"page=%d got=%d new=%d total=%d",
|
||||
p,
|
||||
len(tenants),
|
||||
new_count,
|
||||
len(all_tenants),
|
||||
)
|
||||
|
||||
# Last page — fewer than PAGE_SIZE results means we've exhausted the list
|
||||
@@ -139,22 +210,18 @@ def extract(
|
||||
break
|
||||
|
||||
page = batch_end
|
||||
if not next_proxy:
|
||||
if not cycler:
|
||||
time.sleep(THROTTLE_SECONDS)
|
||||
|
||||
# Write each tenant as a JSONL line, then compress atomically
|
||||
working_path = dest.with_suffix(".working.jsonl")
|
||||
with open(working_path, "w") as f:
|
||||
for tenant in all_tenants:
|
||||
f.write(json.dumps(tenant, separators=(",", ":")) + "\n")
|
||||
bytes_written = compress_jsonl_atomic(working_path, dest)
|
||||
bytes_written = write_jsonl_atomic(dest, all_tenants)
|
||||
logger.info("%d unique venues -> %s", len(all_tenants), dest)
|
||||
|
||||
return {
|
||||
"files_written": 1,
|
||||
"files_skipped": 0,
|
||||
"bytes_written": bytes_written,
|
||||
"cursor_value": year_month,
|
||||
"cursor_value": f"{year}/{month}/{day}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,41 +1,95 @@
|
||||
"""Optional proxy rotation for parallel HTTP fetching.
|
||||
|
||||
Proxies are configured via the PROXY_URLS environment variable (comma-separated).
|
||||
When unset, all functions return None/no-op — extractors fall back to direct requests.
|
||||
Proxies are configured via environment variables. When unset, all functions
|
||||
return None/no-op — extractors fall back to direct requests.
|
||||
|
||||
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).
|
||||
Two-tier escalation: datacenter → residential.
|
||||
Tier 1 (datacenter): PROXY_URLS_DATACENTER — comma-separated paid DC proxies
|
||||
Tier 2 (residential): PROXY_URLS_RESIDENTIAL — comma-separated paid residential proxies
|
||||
|
||||
Tiered circuit breaker:
|
||||
Active tier is used until consecutive failures >= threshold, then escalates
|
||||
to the next tier. Once all tiers are exhausted, is_exhausted() returns True.
|
||||
Escalation is permanent for the duration of the run — no auto-recovery.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_WEBSHARE_PROXIES = 20
|
||||
WEBSHARE_FETCH_TIMEOUT_SECONDS = 10
|
||||
WEBSHARE_MAX_RESPONSE_BYTES = 1024 * 1024 # 1MB
|
||||
|
||||
def load_proxy_urls() -> list[str]:
|
||||
"""Read PROXY_URLS env var (comma-separated). Returns [] if unset.
|
||||
|
||||
Format: http://user:pass@host:port or socks5://host:port
|
||||
def fetch_webshare_proxies(download_url: str, max_proxies: int = MAX_WEBSHARE_PROXIES) -> list[str]:
|
||||
"""Fetch proxy list from the Webshare download API. Returns [] on any error.
|
||||
|
||||
Expected line format: ip:port:username:password
|
||||
Converts to: http://username:password@ip:port
|
||||
|
||||
Bounded: reads at most WEBSHARE_MAX_RESPONSE_BYTES, returns at most max_proxies.
|
||||
"""
|
||||
raw = os.environ.get("PROXY_URLS", "")
|
||||
urls = [u.strip() for u in raw.split(",") if u.strip()]
|
||||
assert max_proxies > 0, f"max_proxies must be positive, got {max_proxies}"
|
||||
assert download_url, "download_url must not be empty"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
download_url,
|
||||
headers={"User-Agent": "padelnomics-extract/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=WEBSHARE_FETCH_TIMEOUT_SECONDS) as resp:
|
||||
raw = resp.read(WEBSHARE_MAX_RESPONSE_BYTES).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch Webshare proxies: %s", e)
|
||||
return []
|
||||
|
||||
urls = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(":")
|
||||
if len(parts) != 4:
|
||||
logger.debug("Skipping malformed proxy line: %r", line)
|
||||
continue
|
||||
ip, port, username, password = parts
|
||||
urls.append(f"http://{username}:{password}@{ip}:{port}")
|
||||
if len(urls) >= max_proxies:
|
||||
break
|
||||
|
||||
logger.info("Fetched %d proxies from Webshare", len(urls))
|
||||
return urls
|
||||
|
||||
|
||||
def load_fallback_proxy_urls() -> list[str]:
|
||||
"""Read PROXY_URLS_FALLBACK env var (comma-separated). Returns [] if unset.
|
||||
def load_proxy_tiers() -> list[list[str]]:
|
||||
"""Assemble proxy tiers in escalation order: datacenter → residential.
|
||||
|
||||
Used as the residential/reliable fallback tier when the primary tier fails.
|
||||
Format: http://user:pass@host:port or socks5://host:port
|
||||
Tier 1 (datacenter): PROXY_URLS_DATACENTER (comma-separated).
|
||||
Tier 2 (residential): PROXY_URLS_RESIDENTIAL (comma-separated).
|
||||
|
||||
Empty tiers are omitted. Returns [] if no proxies configured anywhere.
|
||||
"""
|
||||
raw = os.environ.get("PROXY_URLS_FALLBACK", "")
|
||||
urls = [u.strip() for u in raw.split(",") if u.strip()]
|
||||
return urls
|
||||
tiers: list[list[str]] = []
|
||||
|
||||
for var in ("PROXY_URLS_DATACENTER", "PROXY_URLS_RESIDENTIAL"):
|
||||
raw = os.environ.get(var, "")
|
||||
urls = [u.strip() for u in raw.split(",") if u.strip()]
|
||||
valid = []
|
||||
for url in urls:
|
||||
if not url.startswith(("http://", "https://")):
|
||||
logger.warning("%s contains URL without scheme, skipping: %s", var, url[:60])
|
||||
continue
|
||||
valid.append(url)
|
||||
if valid:
|
||||
tiers.append(valid)
|
||||
|
||||
return tiers
|
||||
|
||||
|
||||
def make_round_robin_cycler(proxy_urls: list[str]):
|
||||
@@ -78,83 +132,181 @@ def make_sticky_selector(proxy_urls: list[str]):
|
||||
return select_proxy
|
||||
|
||||
|
||||
def make_tiered_cycler(
|
||||
primary_urls: list[str],
|
||||
fallback_urls: list[str],
|
||||
threshold: int,
|
||||
) -> dict:
|
||||
"""Thread-safe tiered proxy cycler with circuit breaker.
|
||||
def make_tiered_cycler(tiers: list[list[str]], threshold: int, proxy_failure_limit: int = 3) -> dict:
|
||||
"""Thread-safe N-tier proxy cycler with circuit breaker and per-proxy dead tracking.
|
||||
|
||||
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.
|
||||
Uses tiers[0] until consecutive failures >= threshold, then escalates
|
||||
to tiers[1], then tiers[2], etc. Once all tiers are exhausted,
|
||||
is_exhausted() returns True and next_proxy() returns None.
|
||||
|
||||
Failure counter resets on each escalation — the new tier gets a fresh start.
|
||||
Once exhausted, further record_failure() calls are no-ops.
|
||||
|
||||
Per-proxy dead tracking (when proxy_failure_limit > 0):
|
||||
Individual proxies are marked dead after proxy_failure_limit failures and
|
||||
skipped by next_proxy(). If all proxies in the active tier are dead,
|
||||
next_proxy() auto-escalates to the next tier. Both mechanisms coexist:
|
||||
per-proxy dead tracking removes broken individuals; tier-level threshold
|
||||
catches systemic failure even before any single proxy hits the limit.
|
||||
|
||||
Stale-failure protection:
|
||||
With parallel workers, some threads may fetch a proxy just before the tier
|
||||
escalates and report failure after. record_failure(proxy_url) checks which
|
||||
tier the proxy belongs to and ignores the tier-level circuit breaker if the
|
||||
proxy is from an already-escalated tier. This prevents in-flight failures
|
||||
from a dead tier instantly exhausting the freshly-escalated one.
|
||||
|
||||
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
|
||||
next_proxy() -> str | None — URL from active tier (skips dead), or None
|
||||
record_success(proxy_url=None) -> None — resets consecutive failure counter
|
||||
record_failure(proxy_url=None) -> bool — True if just escalated to next tier
|
||||
is_exhausted() -> bool — True if all tiers exhausted
|
||||
active_tier_index() -> int — 0-based index of current tier
|
||||
tier_count() -> int — total number of tiers
|
||||
dead_proxy_count() -> int — number of individual proxies marked dead
|
||||
|
||||
If primary_urls is empty: always returns from fallback_urls (no circuit breaker needed).
|
||||
If both are empty: next_proxy() always returns None.
|
||||
Edge cases:
|
||||
Empty tiers list: next_proxy() always returns None, is_exhausted() True.
|
||||
Single tier: behaves like the primary-only case, is_exhausted() after threshold.
|
||||
"""
|
||||
assert threshold > 0, f"threshold must be positive, got {threshold}"
|
||||
assert isinstance(tiers, list), f"tiers must be a list, got {type(tiers)}"
|
||||
assert proxy_failure_limit >= 0, f"proxy_failure_limit must be >= 0, got {proxy_failure_limit}"
|
||||
|
||||
lock = threading.Lock()
|
||||
state = {
|
||||
"consecutive_failures": 0,
|
||||
"fallback_active": False,
|
||||
# Reverse map: proxy URL -> tier index. Used in record_failure to ignore
|
||||
# "in-flight" failures from workers that fetched a proxy before escalation —
|
||||
# those failures belong to the old tier and must not count against the new one.
|
||||
proxy_to_tier_idx: dict[str, int] = {
|
||||
url: tier_idx
|
||||
for tier_idx, tier in enumerate(tiers)
|
||||
for url in tier
|
||||
}
|
||||
|
||||
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
|
||||
lock = threading.Lock()
|
||||
cycles = [itertools.cycle(t) for t in tiers]
|
||||
state = {
|
||||
"active_tier": 0,
|
||||
"consecutive_failures": 0,
|
||||
"proxy_failure_counts": {}, # proxy_url -> int
|
||||
"dead_proxies": set(), # proxy URLs marked dead
|
||||
}
|
||||
|
||||
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
|
||||
# Try each remaining tier (bounded: at most len(tiers) escalations)
|
||||
for _ in range(len(tiers) + 1):
|
||||
idx = state["active_tier"]
|
||||
if idx >= len(cycles):
|
||||
return None
|
||||
|
||||
def record_success() -> None:
|
||||
with lock:
|
||||
state["consecutive_failures"] = 0
|
||||
tier_proxies = tiers[idx]
|
||||
tier_len = len(tier_proxies)
|
||||
|
||||
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:
|
||||
# Find a live proxy in this tier (bounded: try each proxy at most once)
|
||||
for _ in range(tier_len):
|
||||
candidate = next(cycles[idx])
|
||||
if candidate not in state["dead_proxies"]:
|
||||
return candidate
|
||||
|
||||
# All proxies in this tier are dead — auto-escalate
|
||||
state["consecutive_failures"] = 0
|
||||
state["active_tier"] += 1
|
||||
new_idx = state["active_tier"]
|
||||
if new_idx < len(tiers):
|
||||
logger.warning(
|
||||
"Circuit open after %d consecutive failures — "
|
||||
"switching to fallback residential proxies",
|
||||
state["consecutive_failures"],
|
||||
"All proxies in tier %d are dead — auto-escalating to tier %d/%d",
|
||||
idx + 1,
|
||||
new_idx + 1,
|
||||
len(tiers),
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Circuit open after %d consecutive failures — "
|
||||
"no fallback configured, aborting run",
|
||||
state["consecutive_failures"],
|
||||
"All proxies in all %d tier(s) are dead — no more fallbacks",
|
||||
len(tiers),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_fallback_active() -> bool:
|
||||
return None # safety fallback
|
||||
|
||||
def record_success(proxy_url: str | None = None) -> None:
|
||||
with lock:
|
||||
return state["fallback_active"]
|
||||
state["consecutive_failures"] = 0
|
||||
if proxy_url is not None:
|
||||
state["proxy_failure_counts"][proxy_url] = 0
|
||||
|
||||
def record_failure(proxy_url: str | None = None) -> bool:
|
||||
"""Increment failure counter. Returns True if just escalated to next tier."""
|
||||
with lock:
|
||||
# Per-proxy dead tracking (additional to tier-level circuit breaker)
|
||||
if proxy_url is not None and proxy_failure_limit > 0:
|
||||
count = state["proxy_failure_counts"].get(proxy_url, 0) + 1
|
||||
state["proxy_failure_counts"][proxy_url] = count
|
||||
if count >= proxy_failure_limit and proxy_url not in state["dead_proxies"]:
|
||||
state["dead_proxies"].add(proxy_url)
|
||||
logger.warning(
|
||||
"Proxy %s marked dead after %d consecutive failures",
|
||||
proxy_url,
|
||||
count,
|
||||
)
|
||||
|
||||
# Tier-level circuit breaker (existing behavior)
|
||||
idx = state["active_tier"]
|
||||
if idx >= len(tiers):
|
||||
# Already exhausted — no-op
|
||||
return False
|
||||
|
||||
# Ignore failures from proxies that belong to an already-escalated tier.
|
||||
# With parallel workers, some threads fetch a proxy just before escalation
|
||||
# and report back after — those stale failures must not penalise the new tier.
|
||||
if proxy_url is not None:
|
||||
proxy_tier = proxy_to_tier_idx.get(proxy_url)
|
||||
if proxy_tier is not None and proxy_tier < idx:
|
||||
return False
|
||||
|
||||
state["consecutive_failures"] += 1
|
||||
if state["consecutive_failures"] < threshold:
|
||||
return False
|
||||
# Threshold reached — escalate
|
||||
state["consecutive_failures"] = 0
|
||||
state["active_tier"] += 1
|
||||
new_idx = state["active_tier"]
|
||||
if new_idx < len(tiers):
|
||||
logger.warning(
|
||||
"Circuit open after %d consecutive failures — "
|
||||
"escalating to proxy tier %d/%d",
|
||||
threshold,
|
||||
new_idx + 1,
|
||||
len(tiers),
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"All %d proxy tier(s) exhausted after %d consecutive failures — "
|
||||
"no more fallbacks",
|
||||
len(tiers),
|
||||
threshold,
|
||||
)
|
||||
return True
|
||||
|
||||
def is_exhausted() -> bool:
|
||||
with lock:
|
||||
return state["active_tier"] >= len(tiers)
|
||||
|
||||
def active_tier_index() -> int:
|
||||
with lock:
|
||||
return state["active_tier"]
|
||||
|
||||
def tier_count() -> int:
|
||||
return len(tiers)
|
||||
|
||||
def dead_proxy_count() -> int:
|
||||
with lock:
|
||||
return len(state["dead_proxies"])
|
||||
|
||||
return {
|
||||
"next_proxy": next_proxy,
|
||||
"record_success": record_success,
|
||||
"record_failure": record_failure,
|
||||
"is_fallback_active": is_fallback_active,
|
||||
"is_exhausted": is_exhausted,
|
||||
"active_tier_index": active_tier_index,
|
||||
"tier_count": tier_count,
|
||||
"dead_proxy_count": dead_proxy_count,
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,19 @@ def get_last_cursor(conn: sqlite3.Connection, extractor: str) -> str | None:
|
||||
return row["cursor_value"] if row else None
|
||||
|
||||
|
||||
_SKIP_RESULT = {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||
|
||||
|
||||
def skip_if_current(conn: sqlite3.Connection, extractor: str, year_month: str) -> dict | None:
|
||||
"""Return an early-exit result dict if this extractor already ran for year_month.
|
||||
|
||||
Returns None when the extractor should proceed with extraction.
|
||||
"""
|
||||
if get_last_cursor(conn, extractor) == year_month:
|
||||
return _SKIP_RESULT
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File I/O helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -176,6 +189,20 @@ def write_gzip_atomic(path: Path, data: bytes) -> int:
|
||||
return len(compressed)
|
||||
|
||||
|
||||
def write_jsonl_atomic(dest: Path, items: list[dict]) -> int:
|
||||
"""Write items as JSONL, then compress atomically to dest (.jsonl.gz).
|
||||
|
||||
Compresses the working-file → JSONL → gzip pattern into one call.
|
||||
Returns compressed bytes written.
|
||||
"""
|
||||
assert items, "items must not be empty"
|
||||
working_path = dest.with_suffix(".working.jsonl")
|
||||
with open(working_path, "w") as f:
|
||||
for item in items:
|
||||
f.write(json.dumps(item, separators=(",", ":")) + "\n")
|
||||
return compress_jsonl_atomic(working_path, dest)
|
||||
|
||||
|
||||
def compress_jsonl_atomic(jsonl_path: Path, dest_path: Path) -> int:
|
||||
"""Compress a JSONL working file to .jsonl.gz atomically, then delete the source.
|
||||
|
||||
|
||||
@@ -54,6 +54,40 @@ chmod 600 "${REPO_DIR}/.env"
|
||||
|
||||
sudo -u "${SERVICE_USER}" bash -c "cd ${REPO_DIR} && ${UV} sync --all-packages"
|
||||
|
||||
# ── rclone config (r2-landing remote) ────────────────────────────────────────
|
||||
|
||||
_env_get() { grep -E "^${1}=" "${REPO_DIR}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'" || true; }
|
||||
|
||||
R2_LANDING_KEY=$(_env_get R2_LANDING_ACCESS_KEY_ID)
|
||||
R2_LANDING_SECRET=$(_env_get R2_LANDING_SECRET_ACCESS_KEY)
|
||||
R2_ENDPOINT=$(_env_get R2_ENDPOINT)
|
||||
|
||||
if [ -n "${R2_LANDING_KEY}" ] && [ -n "${R2_LANDING_SECRET}" ] && [ -n "${R2_ENDPOINT}" ]; then
|
||||
RCLONE_CONF_DIR="/home/${SERVICE_USER}/.config/rclone"
|
||||
RCLONE_CONF="${RCLONE_CONF_DIR}/rclone.conf"
|
||||
|
||||
sudo -u "${SERVICE_USER}" mkdir -p "${RCLONE_CONF_DIR}"
|
||||
|
||||
grep -v '^\[r2-landing\]' "${RCLONE_CONF}" 2>/dev/null > "${RCLONE_CONF}.tmp" || true
|
||||
cat >> "${RCLONE_CONF}.tmp" <<EOF
|
||||
|
||||
[r2-landing]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = ${R2_LANDING_KEY}
|
||||
secret_access_key = ${R2_LANDING_SECRET}
|
||||
endpoint = ${R2_ENDPOINT}
|
||||
acl = private
|
||||
no_check_bucket = true
|
||||
EOF
|
||||
mv "${RCLONE_CONF}.tmp" "${RCLONE_CONF}"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${RCLONE_CONF}"
|
||||
chmod 600 "${RCLONE_CONF}"
|
||||
echo "$(date '+%H:%M:%S') ==> rclone [r2-landing] remote configured."
|
||||
else
|
||||
echo "$(date '+%H:%M:%S') ==> R2_LANDING_* not set — skipping rclone config."
|
||||
fi
|
||||
|
||||
# ── Systemd services ──────────────────────────────────────────────────────────
|
||||
|
||||
cp "${REPO_DIR}/infra/landing-backup/padelnomics-landing-backup.service" /etc/systemd/system/
|
||||
|
||||
@@ -7,15 +7,5 @@ Wants=network-online.target
|
||||
Type=oneshot
|
||||
User=padelnomics_service
|
||||
EnvironmentFile=/opt/padelnomics/.env
|
||||
Environment=LANDING_DIR=/data/padelnomics/landing
|
||||
ExecStart=/usr/bin/rclone sync ${LANDING_DIR} :s3:${LITESTREAM_R2_BUCKET}/padelnomics/landing \
|
||||
--s3-provider Cloudflare \
|
||||
--s3-access-key-id ${LITESTREAM_R2_ACCESS_KEY_ID} \
|
||||
--s3-secret-access-key ${LITESTREAM_R2_SECRET_ACCESS_KEY} \
|
||||
--s3-endpoint https://${LITESTREAM_R2_ENDPOINT} \
|
||||
--s3-no-check-bucket \
|
||||
--exclude ".state.sqlite*"
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=padelnomics-landing-backup
|
||||
ExecStart=/bin/sh -c 'exec /usr/bin/rclone sync /data/padelnomics/landing/ r2-landing:${R2_LANDING_BUCKET}/padelnomics/ --log-level INFO --exclude ".state.sqlite*"'
|
||||
TimeoutStartSec=1800
|
||||
|
||||
@@ -33,10 +33,10 @@ do
|
||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||
uv run --package padelnomics_extract extract
|
||||
|
||||
# Transform
|
||||
# Transform — plan detects new/changed models; run only executes existing plans.
|
||||
LANDING_DIR="${LANDING_DIR:-/data/padelnomics/landing}" \
|
||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||
uv run --package sqlmesh_padelnomics sqlmesh run --select-model "serving.*"
|
||||
uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply
|
||||
|
||||
# Export serving tables to analytics.duckdb (atomic swap).
|
||||
# The web app detects the inode change on next query — no restart needed.
|
||||
|
||||
@@ -8,30 +8,67 @@
|
||||
# entry — optional: function name if not "main" (default: "main")
|
||||
# depends_on — optional: list of workflow names that must run first
|
||||
# proxy_mode — optional: "round-robin" (default) or "sticky"
|
||||
# description — optional: human-readable one-liner shown in the admin UI
|
||||
|
||||
[overpass]
|
||||
module = "padelnomics_extract.overpass"
|
||||
schedule = "monthly"
|
||||
description = "Padel court locations from OpenStreetMap via Overpass API"
|
||||
|
||||
[overpass_tennis]
|
||||
module = "padelnomics_extract.overpass_tennis"
|
||||
schedule = "monthly"
|
||||
description = "Tennis court locations from OpenStreetMap via Overpass API"
|
||||
|
||||
[eurostat]
|
||||
module = "padelnomics_extract.eurostat"
|
||||
schedule = "monthly"
|
||||
description = "City population data from Eurostat Urban Audit"
|
||||
|
||||
[geonames]
|
||||
module = "padelnomics_extract.geonames"
|
||||
schedule = "monthly"
|
||||
description = "Global city/town gazetteer from GeoNames (pop >= 1K)"
|
||||
|
||||
[playtomic_tenants]
|
||||
module = "padelnomics_extract.playtomic_tenants"
|
||||
schedule = "weekly"
|
||||
schedule = "daily"
|
||||
description = "Padel venue directory from Playtomic (names, locations, courts)"
|
||||
|
||||
[playtomic_availability]
|
||||
module = "padelnomics_extract.playtomic_availability"
|
||||
schedule = "daily"
|
||||
depends_on = ["playtomic_tenants"]
|
||||
description = "Morning availability snapshots — slot-level pricing per venue"
|
||||
|
||||
[playtomic_recheck]
|
||||
module = "padelnomics_extract.playtomic_availability"
|
||||
entry = "main_recheck"
|
||||
schedule = "0,30 6-23 * * *"
|
||||
depends_on = ["playtomic_availability"]
|
||||
description = "Intraday availability rechecks for occupancy tracking"
|
||||
|
||||
[census_usa]
|
||||
module = "padelnomics_extract.census_usa"
|
||||
schedule = "monthly"
|
||||
description = "US city/place population from Census Bureau ACS"
|
||||
|
||||
[census_usa_income]
|
||||
module = "padelnomics_extract.census_usa_income"
|
||||
schedule = "monthly"
|
||||
description = "US county median household income from Census Bureau ACS"
|
||||
|
||||
[eurostat_city_labels]
|
||||
module = "padelnomics_extract.eurostat_city_labels"
|
||||
schedule = "monthly"
|
||||
description = "City code-to-name mapping for Eurostat Urban Audit cities"
|
||||
|
||||
[ons_uk]
|
||||
module = "padelnomics_extract.ons_uk"
|
||||
schedule = "monthly"
|
||||
description = "UK local authority population estimates from ONS"
|
||||
|
||||
[gisco]
|
||||
module = "padelnomics_extract.gisco"
|
||||
schedule = "monthly"
|
||||
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
# Building a Padel Hall — Complete Guide
|
||||
# Padel Hall — Question Bank & Gap Analysis
|
||||
|
||||
> **What this file is**: A structured question bank covering the full universe of questions a padel hall entrepreneur needs to answer — from concept to exit. It is **not** an article for publication.
|
||||
>
|
||||
> **Purpose**: Gap analysis — identify which questions Padelnomics already answers (planner, city articles, pipeline data, business plan PDF) and which are unanswered gaps we could fill to improve product value.
|
||||
>
|
||||
> **Coverage legend**:
|
||||
> - `ANSWERED` — fully covered by the planner, city articles, or BP export
|
||||
> - `PARTIAL` — partially addressed; notable gap or missing depth
|
||||
> - `GAP` — not addressed at all; actionable opportunity
|
||||
|
||||
---
|
||||
|
||||
## Gap Analysis Summary
|
||||
|
||||
| Tier | Gap | Estimated Impact | Status |
|
||||
|------|-----|-----------------|--------|
|
||||
| 1 | Subsidies & grants (Germany) | High | Not in product; data exists in `research/padel-hall-economics.md` |
|
||||
| 1 | Buyer segmentation (sports club / commercial / hotel / franchise) | High | Not in planner; segmentation table exists in research |
|
||||
| 1 | Indoor vs outdoor decision framework | High | Planner models both; no comparison table or decision guide |
|
||||
| 1 | OPEX benchmarks shown inline | Medium-High | Planner has inputs; defaults not visually benchmarked |
|
||||
| 2 | Booking platform strategy (Playtomic vs Matchi vs custom) | Medium | Zero guidance; we scrape Playtomic so know it well |
|
||||
| 2 | Depreciation & tax shield | Medium | All calcs pre-tax; Germany: 30% effective, 7yr courts |
|
||||
| 2 | Legal & regulatory checklist (Germany) | Medium | Only permit cost line; Bauantrag, TA Lärm, GmbH etc. missing |
|
||||
| 2 | Court supplier selection framework | Medium | Supplier directory exists; no evaluation criteria |
|
||||
| 2 | Staffing plan template | Medium | BP has narrative field; no structured role × FTE × salary |
|
||||
| 3 | Zero-court location pages (white-space pSEO) | High data value | `location_opportunity_profile` scores them; none published |
|
||||
| 3 | Pre-opening / marketing playbook | Low-Medium | Out of scope; static article possible |
|
||||
| 3 | Catchment area isochrones (drive-time) | Low | Heavy lift; `nearest_padel_court_km` is straight-line only |
|
||||
| 3 | Trend/fad risk quantification | Low | Inherently speculative |
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -16,6 +47,8 @@
|
||||
|
||||
### Market & Demand
|
||||
|
||||
> **COVERAGE: PARTIAL** — Venue counts, density (venues/100K), Market Score, and Opportunity Score per city are all answered by pipeline data (`location_opportunity_profile`) and surfaced in city articles. Missing: actual player counts, competitor utilization rates, household income / age demographics for the catchment area. No drive-time isochrone analysis (Tier 3 gap).
|
||||
|
||||
- How many padel players are in your target area? Is the sport growing locally or are you betting on future adoption?
|
||||
- What's the competitive landscape — how many existing courts within a 20–30 minute drive radius? Are they full? What are their peak/off-peak utilization rates?
|
||||
- What's the demographic profile of your catchment area (income, age, sports participation)?
|
||||
@@ -23,6 +56,8 @@
|
||||
|
||||
### Site & Location
|
||||
|
||||
> **COVERAGE: GAP** — The planner has a rent/land cost input and a `own` toggle for buy vs lease, but there is no guidance on site selection criteria (ceiling height, column spacing, zoning classification, parking ratios). A static article or checklist would cover this. See also Tier 2 gap: legal/regulatory checklist.
|
||||
|
||||
- Do you want to build new (greenfield), convert an existing building (warehouse, industrial hall), or add to an existing sports complex?
|
||||
- What zoning and building regulations apply? Is a padel hall classified as sports, leisure, commercial?
|
||||
- What's the required ceiling height? (Minimum ~8–10m for indoor padel, ideally 10m+)
|
||||
@@ -30,6 +65,8 @@
|
||||
|
||||
### Product & Scope
|
||||
|
||||
> **COVERAGE: PARTIAL** — Court count is fully answered (planner supports 1–12 courts, sensitivity analysis included). Ancillary revenue streams (coaching, F&B, pro shop, events, memberships, corporate) are modelled. Indoor vs outdoor is modelled but there is no structured decision framework comparing CAPEX, revenue ceiling, seasonal risk, noise, and permits (Tier 1 gap #3). Quality level / positioning is not addressed.
|
||||
|
||||
- How many courts? (Typically 4–8 is the sweet spot for a standalone hall; fewer than 4 struggles with profitability, more than 8 requires very strong demand)
|
||||
- Indoor only, outdoor, or hybrid with a retractable/seasonal structure?
|
||||
- What ancillary offerings: pro shop, café/bar/lounge, fitness area, changing rooms, padel school/academy?
|
||||
@@ -37,6 +74,8 @@
|
||||
|
||||
### Financial
|
||||
|
||||
> **COVERAGE: ANSWERED** — All four questions are directly answered by the planner: equity/debt split, rent/land cost, real peak/off-peak prices per city (from Playtomic via `planner_defaults`), utilization ramp curve (Year 1–5), and breakeven utilization (sensitivity grid).
|
||||
|
||||
- What's your total budget, and what's the split between equity and debt?
|
||||
- What rental or land purchase cost can you sustain?
|
||||
- What are realistic court booking prices in your market?
|
||||
@@ -45,6 +84,8 @@
|
||||
|
||||
### Legal & Organizational
|
||||
|
||||
> **COVERAGE: GAP** — Only a permit cost line item exists in CAPEX. No entity guidance (GmbH vs UG vs Verein), no permit checklist, no license types, no insurance guidance. A Germany-first legal/regulatory checklist (Bauantrag, Nutzungsänderung, TA Lärm, Gewerbeerlaubnis, §4 Nr. 22 UStG sports VAT exemption) would be high-value static content (Tier 2 gap #7). Buyer segmentation (sports club vs. commercial) affects entity choice and grant eligibility (Tier 1 gap #2).
|
||||
|
||||
- What legal entity will you use?
|
||||
- Do you need partners (operational, financial, franchise)?
|
||||
- What permits, licenses, and insurance do you need?
|
||||
@@ -56,6 +97,10 @@
|
||||
|
||||
### Phase 1: Feasibility & Concept (Month 1–3)
|
||||
|
||||
> **COVERAGE: ANSWERED** — This phase is fully supported. Market research → city articles (venue density, Market Score, Opportunity Score). Concept development → planner inputs. Location scouting → city articles + planner. Preliminary financial model → planner. Go/no-go → planner output (EBITDA, IRR, NPV).
|
||||
>
|
||||
> Missing: Buyer segmentation (Tier 1 gap #2) — the planner treats all users identically. A "project type" selector (sports club / commercial / hotel / franchise) would adjust CAPEX defaults, grant eligibility, and entity guidance.
|
||||
|
||||
1. **Market research**: Survey local players, visit competing facilities, analyze demographics within a 15–20 minute drive radius. Talk to padel coaches and club organizers.
|
||||
2. **Concept development**: Define your number of courts, target audience, service level, and ancillary revenue streams.
|
||||
3. **Location scouting**: Identify 3–5 candidate sites. Evaluate each on accessibility, visibility, size, ceiling height (if conversion), zoning, and cost.
|
||||
@@ -64,6 +109,8 @@
|
||||
|
||||
### Phase 2: Planning & Design (Month 3–6)
|
||||
|
||||
> **COVERAGE: PARTIAL** — Detailed financial model (step 9) and financing (step 10) are fully answered by the planner (DSCR, covenants, sensitivity). Court supplier selection (step 8) has a partial answer: a supplier directory exists in the product but there is no evaluation framework (Tier 2 gap #8: origin, price/court, warranty, glass type, installation, lead time). Permit process (step 11) is a gap (Tier 2 gap #7). Site security and architect hiring are operational advice, out of scope.
|
||||
|
||||
6. **Secure the site**: Sign a letter of intent or option agreement for purchase or lease.
|
||||
7. **Hire an architect** experienced in sports facilities. They'll produce floor plans, elevations, structural assessments (for conversions), and MEP (mechanical, electrical, plumbing) layouts.
|
||||
8. **Padel court supplier selection**: Get quotes from manufacturers (e.g., Mondo, Padelcreations, MejorSet). Courts come as prefabricated modules — coordinate dimensions, drainage, lighting, and glass specifications with your architect.
|
||||
@@ -73,6 +120,8 @@
|
||||
|
||||
### Phase 3: Construction / Conversion (Month 6–12)
|
||||
|
||||
> **COVERAGE: PARTIAL** — Booking system (step 15) is partially addressed: booking system cost is a planner input, but there is no guidance on platform selection (Playtomic vs Matchi vs custom) despite this being a real decision with revenue and data implications (Tier 2 gap #5). Construction, installation, fit-out, and inspections are operational steps outside Padelnomics' scope.
|
||||
|
||||
12. **Tender and contract construction**: Either a general contractor or construction management approach. Key trades: structural/civil, flooring, HVAC (critical for indoor comfort), electrical (LED court lighting to specific lux standards), plumbing.
|
||||
13. **Install padel courts**: Usually done after the building shell is complete. Courts take 2–4 weeks to install per batch.
|
||||
14. **Fit-out ancillary areas**: Reception, changing rooms, lounge/bar, pro shop.
|
||||
@@ -81,6 +130,8 @@
|
||||
|
||||
### Phase 4: Pre-Opening (Month 10–13)
|
||||
|
||||
> **COVERAGE: PARTIAL** — Staffing plan (step 17): the BP export has a `staffing_plan` narrative field, but there is no structured template with role × FTE × salary defaults. Research benchmarks (€9.9–14.2K/month for 2–3 FTE + manager) could pre-fill this based on court count (Tier 2 gap #9). Marketing playbook (step 18): not addressed; could be a static article (Tier 3 gap #11). Soft/grand opening: out of scope.
|
||||
|
||||
17. **Hire staff**: Manager, reception, coaches, cleaning, potentially F&B staff.
|
||||
18. **Marketing launch**: Social media, local partnerships (sports clubs, corporate wellness), opening event, introductory pricing.
|
||||
19. **Soft opening**: Invite local players, influencers, press for a trial period.
|
||||
@@ -88,6 +139,8 @@
|
||||
|
||||
### Phase 5: Operations & Optimization (Ongoing)
|
||||
|
||||
> **COVERAGE: PARTIAL** — Utilization monitoring and financial review are covered by the planner model. Upsell streams (coaching, equipment, F&B, memberships) are all revenue line items. Community building and dynamic pricing strategy are not addressed — these are operational, not data-driven, and are out of scope.
|
||||
|
||||
21. **Monitor utilization** by court, time slot, and day. Adjust pricing dynamically.
|
||||
22. **Build community**: Leagues, tournaments, social events, corporate bookings.
|
||||
23. **Upsell**: Coaching, equipment, food/beverage, memberships.
|
||||
@@ -97,6 +150,8 @@
|
||||
|
||||
## Plans You Need to Create
|
||||
|
||||
> **COVERAGE: PARTIAL** — Business Plan and Financial Plan are both fully answered (planner + BP PDF export with 15+ narrative sections). Architectural Plans, Marketing Plan, and Legal/Permit Plan are outside the product's scope. Operational Plan is partial: staffing and booking system inputs exist but lack depth (Tier 2 gaps #5, #9).
|
||||
|
||||
- **Business Plan** — the master document covering market analysis, concept, operations plan, management team, and financials. This is what banks and investors want to see.
|
||||
- **Architectural Plans** — floor plans, cross-sections, elevations, structural drawings, MEP plans. Required for permits and construction.
|
||||
- **Financial Plan** — the core of your business plan. Includes investment budget, funding plan, P&L forecast (3–5 years), cash flow forecast, and sensitivity analysis.
|
||||
@@ -112,6 +167,8 @@
|
||||
|
||||
### Investment Budget (CAPEX)
|
||||
|
||||
> **COVERAGE: ANSWERED** — The planner covers all 15+ CAPEX line items for both lease (`rent`) and purchase (`own`) scenarios. Subsidies and grants are **not** modelled (Tier 1 gap #1): `research/padel-hall-economics.md` documents Landessportbund grants (35% for sports clubs), KfW 150 loans, and a real example of €258K → €167K net after grant (padel-court.de). A "Fördermittel" (grants) section in the BP or a callout in DE city articles would surface this.
|
||||
|
||||
| Item | Estimate |
|
||||
|---|---|
|
||||
| Building lease deposit or land | €50,000–€200,000 |
|
||||
@@ -131,6 +188,8 @@ Realistic midpoint for a solid 6-court hall: **~€1.2–1.5M**.
|
||||
|
||||
### Revenue Model
|
||||
|
||||
> **COVERAGE: ANSWERED** — Court utilization × price per hour is the core model. Real peak/off-peak prices per city are pre-filled via `planner_defaults` from Playtomic data. Ramp curve (Year 1–5 utilization), 6 ancillary streams, and monthly seasonal curve are all modelled.
|
||||
|
||||
Core driver: **court utilization × price per hour**.
|
||||
|
||||
- 6 courts × 15 bookable hours/day × 365 days = **32,850 court-hours/year** (theoretical max)
|
||||
@@ -149,6 +208,8 @@ Core driver: **court utilization × price per hour**.
|
||||
|
||||
### Operating Costs (OPEX)
|
||||
|
||||
> **COVERAGE: PARTIAL** — All OPEX line items exist as planner inputs. The defaults are reasonable but are not visually benchmarked against market data (Tier 1 gap #4). Research benchmarks from `research/padel-hall-economics.md` §7: electricity €2.5–4.5K/month, staff €9.9–14.2K/month for 2–3 FTE + manager, rent €8–15K/month. Showing "typical range for your market" next to each OPEX input field would improve trust in the defaults.
|
||||
|
||||
| Cost Item | Year 1 | Year 2 | Year 3 |
|
||||
|---|---|---|---|
|
||||
| Rent / lease | €120k | €123k | €127k |
|
||||
@@ -164,6 +225,8 @@ Core driver: **court utilization × price per hour**.
|
||||
|
||||
### Profitability
|
||||
|
||||
> **COVERAGE: ANSWERED** — EBITDA, EBITDA margin, debt service, and free cash flow after debt are all computed by the planner for all 60 months.
|
||||
|
||||
| Metric | Year 1 | Year 2 | Year 3 |
|
||||
|---|---|---|---|
|
||||
| **EBITDA** | €310k | €577k | €759k |
|
||||
@@ -173,6 +236,8 @@ Core driver: **court utilization × price per hour**.
|
||||
|
||||
### Key Metrics to Track
|
||||
|
||||
> **COVERAGE: ANSWERED** — Payback period, IRR (equity + project), NPV, MOIC, DSCR per year, breakeven utilization, and revenue per available hour are all computed and displayed.
|
||||
|
||||
- **Payback period**: Typically 3–5 years for a well-run padel hall
|
||||
- **ROI on equity**: If you put in €500k equity and generate €300k+ annual free cash flow by year 3, that's a 60%+ cash-on-cash return
|
||||
- **Breakeven utilization**: Usually around 35–40% — below which you lose money
|
||||
@@ -180,12 +245,18 @@ Core driver: **court utilization × price per hour**.
|
||||
|
||||
### Sensitivity Analysis
|
||||
|
||||
> **COVERAGE: ANSWERED** — 12-step utilization sensitivity and 8-step price sensitivity are both shown as grids, each including DSCR values.
|
||||
|
||||
Model what happens if utilization is 10% lower than planned, if the average price drops by €5, or if construction costs overrun by 20%. This is what banks want to see — that you survive the downside.
|
||||
|
||||
---
|
||||
|
||||
## How to Decide Where to Build
|
||||
|
||||
> **COVERAGE: PARTIAL overall** — The product answers competition mapping (venue density, Opportunity Score) and rent/cost considerations (planner input). Missing: drive-time catchment analysis (Tier 3 gap #12 — would need isochrone API), accessibility/visibility/building suitability assessment (static checklist possible), growth trajectory (no new-development data), and regulatory environment (Tier 2 gap #7).
|
||||
>
|
||||
> **Tier 3 opportunity**: `location_opportunity_profile` scores thousands of GeoNames locations including zero-court towns. Only venues with existing courts get a public article. Generating pSEO pages for top-scoring zero-court locations would surface "build here" recommendations (white-space pages).
|
||||
|
||||
1. **Catchment area analysis**: Draw a 15-minute and 30-minute drive-time radius around candidate sites. Analyze population density, household income, age distribution (25–55 is the core padel demographic), and existing sports participation rates.
|
||||
|
||||
2. **Competition mapping**: Map every existing padel facility within 30 minutes. Call them, check their booking systems — are courts booked out at peak? If competitors are running at 80%+ utilization, that's a strong signal of unmet demand.
|
||||
@@ -208,70 +279,104 @@ Model what happens if utilization is 10% lower than planned, if the average pric
|
||||
|
||||
### NPV & IRR
|
||||
|
||||
> **COVERAGE: ANSWERED** — Both equity IRR and project IRR are computed. NPV is shown with the WACC input. Hurdle rate is a user input.
|
||||
|
||||
Discount your projected free cash flows at your WACC (or required return on equity if all-equity financed) to get a net present value. The IRR tells you whether the project clears your hurdle rate. For a padel hall, you'd typically want an unlevered IRR of 15–25% to justify the risk of a single-asset, operationally intensive business. Compare this against alternative uses of your capital.
|
||||
|
||||
### WACC & Cost of Capital
|
||||
|
||||
> **COVERAGE: ANSWERED** — WACC is a planner input used in NPV calculations. Debt cost and equity cost are separately configurable.
|
||||
|
||||
If you're blending debt and equity, calculate your weighted average cost of capital properly. Bank debt for a sports facility might run 4–7% depending on jurisdiction and collateral. Your equity cost should reflect the illiquidity premium and operational risk — this isn't a passive real estate investment, it's an operating business. A reasonable cost of equity might be 12–20%.
|
||||
|
||||
### Terminal Value
|
||||
|
||||
> **COVERAGE: ANSWERED** — Terminal value is computed as EBITDA × exit multiple at the end of the hold period. MOIC and value bridge are displayed.
|
||||
|
||||
If you model 5 years of explicit cash flows, you need a terminal value. You can use a perpetuity growth model (FCF year 5 × (1+g) / (WACC – g)) or an exit multiple. For the exit multiple approach, think about what a buyer would pay — likely 4–7x EBITDA for a mature, well-run single-location padel hall, potentially higher if it's part of a multi-site rollout story.
|
||||
|
||||
### Lease vs. Buy
|
||||
|
||||
> **COVERAGE: ANSWERED** — The `own` toggle in the planner changes the entire CAPEX/OPEX structure: land purchase replaces lease deposit, mortgage replaces rent, and property appreciation is modelled in terminal value.
|
||||
|
||||
A critical capital allocation decision. Buying the property ties up far more capital but gives you residual asset value and eliminates landlord risk. Leasing preserves capital for operations and expansion but exposes you to rent increases and lease termination risk. Model both scenarios and compare the risk-adjusted NPV. Also consider sale-and-leaseback if you build on owned land.
|
||||
|
||||
### Operating Leverage
|
||||
|
||||
> **COVERAGE: ANSWERED** — The sensitivity grids explicitly show how a 10% utilization swing affects EBITDA and DSCR.
|
||||
|
||||
A padel hall has high fixed costs (rent, staff base, debt service) and relatively low variable costs. This means profitability is extremely sensitive to utilization. Model the operating leverage explicitly — a 10% swing in utilization might cause a 25–30% swing in EBITDA. This is both the opportunity and the risk.
|
||||
|
||||
### Depreciation & Tax Shield
|
||||
|
||||
> **COVERAGE: GAP** — All planner calculations are pre-tax (Tier 2 gap #6). Adding a depreciation schedule and effective tax rate would materially improve the financial model for Germany: 7-year depreciation for courts/equipment, ~30% effective tax rate (15% KSt + 14% GewSt). This would require jurisdiction selection (start with Germany only). Non-trivial but the most common user geography.
|
||||
|
||||
Padel courts depreciate over 7–10 years, building fit-out over 10–15 years, equipment over 3–5 years. The depreciation tax shield is meaningful. Interest expense on debt is also tax-deductible. Model your effective tax rate and the present value of these shields — they improve your after-tax returns materially.
|
||||
|
||||
### Working Capital Cycle
|
||||
|
||||
> **COVERAGE: ANSWERED** — Pre-opening cash burn and ramp-up period are modelled in the 60-month cash flow. Working capital reserve is a CAPEX line item.
|
||||
|
||||
Padel halls are generally working-capital-light (customers pay at booking or on arrival, you pay suppliers on 30–60 day terms). But model the initial ramp-up period where you're carrying costs before revenue reaches steady state. The pre-opening cash burn and first 6–12 months of sub-breakeven operation is where most of your working capital risk sits.
|
||||
|
||||
### Scenario & Sensitivity Analysis
|
||||
|
||||
> **COVERAGE: ANSWERED** — Utilization sensitivity (12 steps) and price sensitivity (8 steps) grids are shown, both with DSCR. Bear/base/bull narrative is covered in the BP export.
|
||||
|
||||
Model three scenarios (bear/base/bull) varying utilization, pricing, and cost overruns simultaneously. Identify the breakeven utilization rate precisely. A Monte Carlo simulation on the key variables (utilization, average price, construction cost, ramp-up speed) gives you a probability distribution of outcomes rather than a single point estimate.
|
||||
|
||||
### Exit Strategy & Valuation
|
||||
|
||||
> **COVERAGE: ANSWERED** — Hold period, exit EBITDA multiple, terminal value, MOIC, and value bridge are all displayed in the planner.
|
||||
|
||||
Think about this upfront. Are you building to hold and cash-flow, or building to sell to a consolidator or franchise operator? The exit multiple depends heavily on whether you've built a transferable business (brand, systems, trained staff, long lease) or an owner-dependent operation. Multi-site operators and franchise groups trade at higher multiples (6–10x EBITDA) than single sites.
|
||||
|
||||
### Optionality Value
|
||||
|
||||
> **COVERAGE: GAP** — Real option value (second location, franchise, repurposing) is mentioned in the BP narrative but not quantified. Out of scope for the planner; noting as a caveat in the BP export text would be sufficient.
|
||||
|
||||
A successful first hall gives you the option to expand — second location, franchise model, or selling the playbook. This real option has value that a static DCF doesn't capture. Similarly, if you own the land/building, you have conversion optionality (the building could be repurposed if padel demand fades).
|
||||
|
||||
### Counterparty & Concentration Risk
|
||||
|
||||
> **COVERAGE: PARTIAL** — The planner models this implicitly (single-site, single-sport), and DSCR warnings flag over-leverage. No explicit counterparty risk section. Mentioning it in the BP risk narrative would be low-effort coverage.
|
||||
|
||||
You're exposed to a single landlord (lease risk), a single location (demand risk), and potentially a single sport (trend risk). A bank or sophisticated investor will flag all three. Mitigants include long lease terms with caps on escalation, diversified revenue streams (F&B, events, coaching), and contractual protections.
|
||||
|
||||
### Subsidies & Grants
|
||||
|
||||
> **COVERAGE: GAP — Tier 1 priority.** `research/padel-hall-economics.md` documents: Landessportbund grants (up to 35% CAPEX for registered sports clubs), KfW 150 low-interest loans, and a worked example: €258K gross → €167K net CAPEX after grant. The planner has no grants input. Quick wins: (a) add a "Fördermittel" accordion section to DE city articles; (b) add a grant percentage input to the planner CAPEX section (reduces total investment and boosts IRR). Note: grant eligibility depends on buyer type (Tier 1 gap #2) — sports clubs qualify, commercial operators typically do not.
|
||||
|
||||
Many municipalities and national sports bodies offer grants or subsidized loans for sports infrastructure. In some European countries, this can cover 10–30% of CAPEX. Factor this into your funding plan — it's essentially free equity that boosts your returns.
|
||||
|
||||
### VAT & Tax Structuring
|
||||
|
||||
> **COVERAGE: GAP** — Not modelled. Germany-specific: court rental may qualify for §4 Nr. 22 UStG sports VAT exemption (0% VAT) if operated by a non-commercial entity; commercial operators pay 19% VAT on court rental. F&B is 19% (or 7% eat-in). Getting this wrong materially affects revenue net-of-VAT. Worth a callout in the legal/regulatory article (Tier 2 gap #7).
|
||||
|
||||
Depending on your jurisdiction, court rental may be VAT-exempt or reduced-rate (sports exemption), while F&B is standard-rated. This affects pricing strategy and cash flow. The entity structure (single GmbH, holding structure, partnership) has implications for profit extraction, liability, and eventual exit taxation. Worth getting tax advice early.
|
||||
|
||||
### Insurance & Business Interruption
|
||||
|
||||
> **COVERAGE: PARTIAL** — Insurance is a planner OPEX line item. No guidance on coverage types or BI insurance sizing. Low priority to expand.
|
||||
|
||||
Price in comprehensive insurance — property, liability, business interruption. A fire or structural issue that shuts you down for 3 months could be existential without BI coverage. This is a real cost that's often underestimated.
|
||||
|
||||
### Covenant Compliance
|
||||
|
||||
> **COVERAGE: ANSWERED** — DSCR is computed for each of the 5 years and shown with a warning band. LTV warnings are also displayed.
|
||||
|
||||
If you take bank debt, you'll likely face covenants — DSCR (debt service coverage ratio) minimums of 1.2–1.5x, leverage caps, possibly revenue milestones. Model your covenant headroom explicitly. Breaching a covenant in year 1 during ramp-up is a real risk if you've over-leveraged.
|
||||
|
||||
### Inflation Sensitivity
|
||||
|
||||
> **COVERAGE: ANSWERED** — The planner has separate `revenue_growth_rate` and `opex_growth_rate` inputs, allowing asymmetric inflation scenarios.
|
||||
|
||||
Energy costs, staff wages, and maintenance all inflate. Can you pass these through via price increases without killing utilization? Model a scenario where costs inflate at 3–5% but you can only raise prices by 2–3%.
|
||||
|
||||
### Residual / Liquidation Value
|
||||
|
||||
> **COVERAGE: PARTIAL** — Terminal/exit value is modelled (EBITDA multiple). A true liquidation scenario (courts resale, lease termination penalties, building write-off) is not separately modelled. Sufficient for the current product.
|
||||
|
||||
In a downside scenario, what are your assets worth? Padel courts have some resale value. Building improvements are largely sunk. If you've leased, your downside is limited to equity invested plus any personal guarantees. If you've bought property, the real estate retains value but may take time to sell. Model the liquidation scenario honestly.
|
||||
|
||||
---
|
||||
@@ -280,24 +385,34 @@ In a downside scenario, what are your assets worth? Padel courts have some resal
|
||||
|
||||
### Existential Risks
|
||||
|
||||
> **COVERAGE: PARTIAL** — Trend/fad risk is acknowledged in the BP narrative but not quantified (Tier 3 gap #13). FIP/Playtomic data (7,187 new courts globally in 2024, +26% YoY new clubs) exists but long-term quantification is inherently speculative. Force majeure/pandemic risk is not addressed; a reserve fund input (CAPEX working capital) provides partial mitigation modelling.
|
||||
|
||||
- **Trend / Fad Risk**: Padel is booming now, but so did squash in the 1980s. You're locking in a 10–15 year investment thesis on a sport that may plateau or decline. The key question is whether padel reaches self-sustaining critical mass in your market or stays a novelty. If utilization drops from 65% to 35% in year 5 because the hype fades, your entire model breaks. This is largely unhedgeable.
|
||||
|
||||
- **Force Majeure / Pandemic Risk**: COVID shut down indoor sports facilities for months. Insurance may not cover it. Having enough cash reserves or credit facilities to survive 3–6 months of zero revenue is prudent.
|
||||
|
||||
### Construction & Development Risks
|
||||
|
||||
> **COVERAGE: PARTIAL** — A contingency/overrun percentage is a planner CAPEX input. Delay cost (carrying costs during construction) is not explicitly modelled.
|
||||
|
||||
- **Construction Cost Overruns & Delays**: Sports facility builds routinely overrun by 15–30%. Every month of delay is a month of carrying costs (rent, debt service, staff already hired) with zero revenue. Build a contingency buffer of 15–20% of CAPEX minimum and negotiate fixed-price construction contracts where possible.
|
||||
|
||||
### Property & Lease Risks
|
||||
|
||||
> **COVERAGE: GAP** — No lease-term inputs or landlord risk guidance. The `own` toggle handles the buy scenario. A callout in the BP template about minimum lease length (15+ years, renewal options) would be useful but is low priority.
|
||||
|
||||
- **Landlord Risk**: If you're leasing, you're spending €500k+ fitting out someone else's building. What happens if the landlord sells, goes bankrupt, or refuses to renew? You need a long lease (15+ years), with options to renew, and ideally a step-in right or compensation clause for tenant improvements.
|
||||
|
||||
### Competitive Risks
|
||||
|
||||
> **COVERAGE: PARTIAL** — City articles show existing venue density and Opportunity Score. The planner does not model a "competitor opens nearby" scenario. A simple sensitivity scenario (utilization drop) is the best proxy available in the current model.
|
||||
|
||||
- **Cannibalization from New Entrants**: Your success is visible — full courts, long waitlists. This attracts competitors. Someone opens a new hall 10 minutes away, and your utilization drops from 70% to 50%. There's no real moat in padel besides location, community loyalty, and service quality. Model what happens when a competitor opens nearby in year 3.
|
||||
|
||||
### Operational Risks
|
||||
|
||||
> **COVERAGE: PARTIAL** — Court maintenance OPEX and maintenance reserve are planner inputs. F&B, staffing, and booking platform risks are not addressed. See Tier 2 gaps #5 (booking platform strategy) and #9 (staffing plan). Seasonality is fully modelled (12-month outdoor seasonal curve; monthly cash flow).
|
||||
|
||||
- **Key Person Dependency**: If the whole operation depends on one founder-operator or one star coach who brings all the members, that's a fragility. Illness, burnout, or departure can crater the business.
|
||||
|
||||
- **Staff Retention & Labor Market**: Good facility managers, coaches, and front-desk staff with a hospitality mindset are hard to find and keep. Turnover is expensive and disruptive. In tight labor markets, wage pressure can erode margins.
|
||||
@@ -310,6 +425,8 @@ In a downside scenario, what are your assets worth? Padel courts have some resal
|
||||
|
||||
### Financial Risks
|
||||
|
||||
> **COVERAGE: PARTIAL** — Energy volatility: energy OPEX is a modelled input with growth rate, but no locking/hedging guidance. Financing environment: debt rate is a planner input; stress-test at +2% is covered by the sensitivity grid indirectly. Personal guarantee and customer concentration: not addressed (out of scope for data-driven product). Inflation pass-through: answered (separate revenue vs OPEX growth rates).
|
||||
|
||||
- **Energy Price Volatility**: Indoor padel halls consume significant energy. Energy costs spiking can destroy margins. Consider locking in energy contracts, investing in solar panels, or using LED lighting and efficient HVAC to reduce exposure.
|
||||
|
||||
- **Financing Environment**: If interest rates rise between when you plan the project and when you draw down the loan, your debt service costs increase. Lock in rates where possible, or stress-test your model at rates 2% higher than current.
|
||||
@@ -322,22 +439,32 @@ In a downside scenario, what are your assets worth? Padel courts have some resal
|
||||
|
||||
### Regulatory & Legal Risks
|
||||
|
||||
> **COVERAGE: GAP — Tier 2 priority.** Noise complaints (TA Lärm), injury liability, and permit risks are all unaddressed. A Germany-first regulatory checklist article would cover: Bauantrag, Nutzungsänderung, TA Lärm compliance, GmbH vs UG formation, Gewerbeerlaubnis, §4 Nr. 22 UStG sports VAT, and Gaststättengesetz (liquor license). High value for Phase 1/2 users who are evaluating feasibility.
|
||||
|
||||
- **Noise Complaints**: Padel is loud — the ball hitting glass walls generates significant noise. Neighbors can complain and municipal authorities can impose operating hour restrictions or require expensive sound mitigation. Check local noise ordinances thoroughly before committing.
|
||||
|
||||
- **Injury Liability**: Padel involves glass walls, fast-moving balls, and quick lateral movement. Player injuries happen. Proper insurance, waiver systems, and court maintenance protocols are essential.
|
||||
|
||||
### Technology & Platform Risks
|
||||
|
||||
> **COVERAGE: GAP — Tier 2 priority.** Booking platform dependency is a real decision point for operators (Playtomic commission ~15–20%, data ownership implications, competitor steering risk). We scrape Playtomic and know it intimately. A standalone article "Playtomic vs Matchi vs eigenes System" or a section in the BP template would address this. The booking system commission rate is already a planner input — we could link to a decision guide from there.
|
||||
|
||||
- **Booking Platform Dependency**: If you rely on a third-party booking platform like Playtomic, you're giving them access to your customer relationships and paying commission. They could raise fees, change terms, or steer demand to competitors.
|
||||
|
||||
### Reputational Risks
|
||||
|
||||
> **COVERAGE: GAP** — Not addressed. Out of scope for a data-driven product; operational advice.
|
||||
|
||||
- **Brand / Reputation Risk**: One viral negative review, a hygiene issue, a safety incident, or a social media complaint can disproportionately hurt a local leisure business.
|
||||
|
||||
### Currency & External Risks
|
||||
|
||||
> **COVERAGE: GAP** — FX risk from Spanish/Italian manufacturers is not modelled. Minor; most German buyers pay in EUR. Note in BP template as a caveat if importing outside Eurozone.
|
||||
|
||||
- **Currency Risk**: Relevant if importing courts or equipment from another currency zone — padel court manufacturers are often Spanish or Italian, so FX moves can affect CAPEX if you're outside the Eurozone.
|
||||
|
||||
### Opportunity Cost
|
||||
|
||||
> **COVERAGE: PARTIAL** — IRR and NPV implicitly address opportunity cost (you enter the hurdle rate as WACC/cost of equity). No explicit comparison against passive investment alternatives is shown. Sufficient for current product.
|
||||
|
||||
The capital, time, and energy you put into this project could go elsewhere. If you could earn 8–10% passively in diversified investments, a padel hall needs to deliver meaningfully more on a risk-adjusted basis to justify the concentration, illiquidity, and personal time commitment.
|
||||
290
scripts/check_pipeline.py
Normal file
290
scripts/check_pipeline.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Diagnostic script: check row counts at every layer of the pricing pipeline.
|
||||
|
||||
Run on prod via SSH:
|
||||
DUCKDB_PATH=/opt/padelnomics/data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||
|
||||
Or locally:
|
||||
DUCKDB_PATH=data/lakehouse.duckdb uv run python scripts/check_pipeline.py
|
||||
|
||||
Read-only — never writes to the database.
|
||||
|
||||
Handles the DuckDB catalog naming quirk: when the file is named lakehouse.duckdb,
|
||||
the catalog is "lakehouse" not "local". SQLMesh views may reference the wrong catalog,
|
||||
so we fall back to querying physical tables (sqlmesh__<schema>.<table>__<hash>).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import duckdb
|
||||
|
||||
DUCKDB_PATH = os.environ.get("DUCKDB_PATH", "data/lakehouse.duckdb")
|
||||
|
||||
PIPELINE_TABLES = [
|
||||
("staging", "stg_playtomic_availability"),
|
||||
("foundation", "fct_availability_slot"),
|
||||
("foundation", "dim_venue_capacity"),
|
||||
("foundation", "fct_daily_availability"),
|
||||
("serving", "venue_pricing_benchmarks"),
|
||||
("serving", "pseo_city_pricing"),
|
||||
]
|
||||
|
||||
|
||||
def _use_catalog(con):
|
||||
"""Detect and USE the database catalog so schema-qualified queries work."""
|
||||
catalogs = [
|
||||
row[0]
|
||||
for row in con.execute(
|
||||
"SELECT catalog_name FROM information_schema.schemata"
|
||||
).fetchall()
|
||||
]
|
||||
# Pick the non-system catalog (not 'system', 'temp', 'memory')
|
||||
user_catalogs = [c for c in set(catalogs) if c not in ("system", "temp", "memory")]
|
||||
if user_catalogs:
|
||||
catalog = user_catalogs[0]
|
||||
con.execute(f"USE {catalog}")
|
||||
return catalog
|
||||
return None
|
||||
|
||||
|
||||
def _find_physical_table(con, schema, table):
|
||||
"""Find the SQLMesh physical table name for a logical table.
|
||||
|
||||
SQLMesh stores physical tables as:
|
||||
sqlmesh__<schema>.<schema>__<table>__<hash>
|
||||
"""
|
||||
sqlmesh_schema = f"sqlmesh__{schema}"
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT table_schema, table_name "
|
||||
"FROM information_schema.tables "
|
||||
f"WHERE table_schema = '{sqlmesh_schema}' "
|
||||
f"AND table_name LIKE '{schema}__{table}%' "
|
||||
"ORDER BY table_name "
|
||||
"LIMIT 1"
|
||||
).fetchall()
|
||||
if rows:
|
||||
return f"{rows[0][0]}.{rows[0][1]}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _query_table(con, schema, table):
|
||||
"""Try logical view first, fall back to physical table. Returns (fqn, count) or (fqn, error_str)."""
|
||||
logical = f"{schema}.{table}"
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {logical}").fetchone()
|
||||
return logical, count
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
physical = _find_physical_table(con, schema, table)
|
||||
if physical:
|
||||
try:
|
||||
(count,) = con.execute(f"SELECT COUNT(*) FROM {physical}").fetchone()
|
||||
return f"{physical} (physical)", count
|
||||
except Exception as e:
|
||||
return f"{physical} (physical)", f"ERROR: {e}"
|
||||
|
||||
return logical, "ERROR: view broken, no physical table found"
|
||||
|
||||
|
||||
def _query_sql(con, sql, schema_tables):
|
||||
"""Execute SQL, falling back to rewritten SQL using physical table names if views fail.
|
||||
|
||||
schema_tables: list of (schema, table) tuples used in the SQL, in order of appearance.
|
||||
The SQL must use {schema}.{table} format for these references.
|
||||
"""
|
||||
try:
|
||||
return con.execute(sql)
|
||||
except Exception:
|
||||
# Rewrite SQL to use physical table names
|
||||
rewritten = sql
|
||||
for schema, table in schema_tables:
|
||||
physical = _find_physical_table(con, schema, table)
|
||||
if physical:
|
||||
rewritten = rewritten.replace(f"{schema}.{table}", physical)
|
||||
else:
|
||||
raise
|
||||
return con.execute(rewritten)
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(DUCKDB_PATH):
|
||||
print(f"ERROR: {DUCKDB_PATH} not found")
|
||||
sys.exit(1)
|
||||
|
||||
con = duckdb.connect(DUCKDB_PATH, read_only=True)
|
||||
|
||||
print(f"Database: {DUCKDB_PATH}")
|
||||
print(f"DuckDB version: {con.execute('SELECT version()').fetchone()[0]}")
|
||||
|
||||
catalog = _use_catalog(con)
|
||||
if catalog:
|
||||
print(f"Catalog: {catalog}")
|
||||
print()
|
||||
|
||||
# ── Row counts at each layer ──────────────────────────────────────────
|
||||
print("=" * 60)
|
||||
print("PIPELINE ROW COUNTS")
|
||||
print("=" * 60)
|
||||
|
||||
for schema, table in PIPELINE_TABLES:
|
||||
fqn, result = _query_table(con, schema, table)
|
||||
if isinstance(result, int):
|
||||
print(f" {fqn:55s} {result:>10,} rows")
|
||||
else:
|
||||
print(f" {fqn:55s} {result}")
|
||||
|
||||
# ── Date range in fct_daily_availability ──────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("DATE RANGE: fct_daily_availability")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
MIN(snapshot_date) AS min_date,
|
||||
MAX(snapshot_date) AS max_date,
|
||||
COUNT(DISTINCT snapshot_date) AS distinct_days,
|
||||
CURRENT_DATE AS today,
|
||||
CURRENT_DATE - INTERVAL '30 days' AS window_start
|
||||
FROM foundation.fct_daily_availability
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchone()
|
||||
if row:
|
||||
min_date, max_date, days, today, window_start = row
|
||||
print(f" Min snapshot_date: {min_date}")
|
||||
print(f" Max snapshot_date: {max_date}")
|
||||
print(f" Distinct days: {days}")
|
||||
print(f" Today: {today}")
|
||||
print(f" 30-day window start: {window_start}")
|
||||
if max_date and str(max_date) < str(window_start):
|
||||
print()
|
||||
print(" *** ALL DATA IS OUTSIDE THE 30-DAY WINDOW ***")
|
||||
print(" This is why venue_pricing_benchmarks is empty.")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# ── HAVING filter impact in venue_pricing_benchmarks ──────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("HAVING FILTER IMPACT (venue_pricing_benchmarks)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
WITH venue_stats AS (
|
||||
SELECT
|
||||
da.tenant_id,
|
||||
da.country_code,
|
||||
da.city,
|
||||
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
||||
FROM foundation.fct_daily_availability da
|
||||
WHERE TRY_CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
|
||||
AND da.occupancy_rate IS NOT NULL
|
||||
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
||||
GROUP BY da.tenant_id, da.country_code, da.city
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS total_venues,
|
||||
COUNT(*) FILTER (WHERE days_observed >= 3) AS venues_passing_having,
|
||||
COUNT(*) FILTER (WHERE days_observed < 3) AS venues_failing_having,
|
||||
MAX(days_observed) AS max_days,
|
||||
MIN(days_observed) AS min_days
|
||||
FROM venue_stats
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchone()
|
||||
if row:
|
||||
total, passing, failing, max_d, min_d = row
|
||||
print(f" Venues in 30-day window: {total}")
|
||||
print(f" Venues with >= 3 days (PASSING): {passing}")
|
||||
print(f" Venues with < 3 days (FILTERED): {failing}")
|
||||
print(f" Max days observed: {max_d}")
|
||||
print(f" Min days observed: {min_d}")
|
||||
if total == 0:
|
||||
print()
|
||||
print(" *** NO VENUES IN 30-DAY WINDOW — check fct_daily_availability dates ***")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# ── Occupancy rate distribution ───────────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("OCCUPANCY RATE DISTRIBUTION (fct_daily_availability)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
rows = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN occupancy_rate IS NULL THEN 'NULL'
|
||||
WHEN occupancy_rate < 0 THEN '< 0 (invalid)'
|
||||
WHEN occupancy_rate > 1.5 THEN '> 1.5 (filtered)'
|
||||
WHEN occupancy_rate <= 0.25 THEN '0 – 0.25'
|
||||
WHEN occupancy_rate <= 0.50 THEN '0.25 – 0.50'
|
||||
WHEN occupancy_rate <= 0.75 THEN '0.50 – 0.75'
|
||||
ELSE '0.75 – 1.0+'
|
||||
END AS bucket,
|
||||
COUNT(*) AS cnt
|
||||
FROM foundation.fct_daily_availability
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""",
|
||||
[("foundation", "fct_daily_availability")],
|
||||
).fetchall()
|
||||
for bucket, cnt in rows:
|
||||
print(f" {bucket:25s} {cnt:>10,}")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
# ── dim_venue_capacity join coverage ──────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("JOIN COVERAGE: fct_availability_slot → dim_venue_capacity")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
row = _query_sql(
|
||||
con,
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT a.tenant_id) AS slot_tenants,
|
||||
COUNT(DISTINCT c.tenant_id) AS capacity_tenants,
|
||||
COUNT(DISTINCT a.tenant_id) - COUNT(DISTINCT c.tenant_id) AS missing_capacity
|
||||
FROM foundation.fct_availability_slot a
|
||||
LEFT JOIN foundation.dim_venue_capacity c ON a.tenant_id = c.tenant_id
|
||||
""",
|
||||
[
|
||||
("foundation", "fct_availability_slot"),
|
||||
("foundation", "dim_venue_capacity"),
|
||||
],
|
||||
).fetchone()
|
||||
if row:
|
||||
slot_t, cap_t, missing = row
|
||||
print(f" Tenants in fct_availability_slot: {slot_t}")
|
||||
print(f" Tenants with capacity match: {cap_t}")
|
||||
print(f" Tenants missing capacity: {missing}")
|
||||
if missing and missing > 0:
|
||||
print(f" *** {missing} tenants dropped by INNER JOIN to dim_venue_capacity ***")
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
|
||||
con.close()
|
||||
print()
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Download NUTS-2 boundary GeoJSON from Eurostat GISCO.
|
||||
|
||||
One-time (or on NUTS revision) download of NUTS-2 boundary polygons used for
|
||||
spatial income resolution in dim_locations. Stored uncompressed because DuckDB's
|
||||
ST_Read function cannot read gzipped files.
|
||||
|
||||
NUTS classification changes approximately every 7 years. Current revision: 2021.
|
||||
|
||||
Output: {LANDING_DIR}/gisco/2024/01/nuts2_boundaries.geojson (~5MB, uncompressed)
|
||||
|
||||
Usage:
|
||||
uv run python scripts/download_gisco_nuts.py [--landing-dir data/landing]
|
||||
|
||||
Idempotent: skips download if the file already exists.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import niquests
|
||||
|
||||
# NUTS 2021 revision, 20M scale (1:20,000,000), WGS84 (EPSG:4326), LEVL_2 only.
|
||||
# 20M resolution gives simplified polygons that are fast for point-in-polygon
|
||||
# matching without sacrificing accuracy at the NUTS-2 boundary level.
|
||||
GISCO_URL = (
|
||||
"https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
|
||||
"NUTS_RG_20M_2021_4326_LEVL_2.geojson"
|
||||
)
|
||||
|
||||
# Fixed partition: NUTS boundaries are a static reference file, not time-series data.
|
||||
# Use the NUTS revision year as the partition to make the source version explicit.
|
||||
DEST_REL_PATH = "gisco/2024/01/nuts2_boundaries.geojson"
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
def download_nuts_boundaries(landing_dir: Path) -> None:
|
||||
dest = landing_dir / DEST_REL_PATH
|
||||
if dest.exists():
|
||||
print(f"Already exists (skipping): {dest}")
|
||||
return
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Downloading NUTS-2 boundaries from GISCO...")
|
||||
print(f" URL: {GISCO_URL}")
|
||||
|
||||
with niquests.Session() as session:
|
||||
resp = session.get(GISCO_URL, timeout=HTTP_TIMEOUT_SECONDS)
|
||||
resp.raise_for_status()
|
||||
|
||||
content = resp.content
|
||||
assert len(content) > 100_000, (
|
||||
f"GeoJSON too small ({len(content)} bytes) — download may have failed"
|
||||
)
|
||||
assert b'"FeatureCollection"' in content, "Response does not look like GeoJSON"
|
||||
|
||||
# Write uncompressed — ST_Read requires a plain file
|
||||
tmp = dest.with_suffix(".geojson.tmp")
|
||||
tmp.write_bytes(content)
|
||||
tmp.rename(dest)
|
||||
|
||||
size_mb = len(content) / 1_000_000
|
||||
print(f" Written: {dest} ({size_mb:.1f} MB)")
|
||||
print("Done. Run SQLMesh plan to rebuild stg_nuts2_boundaries.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--landing-dir", default="data/landing", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.landing_dir.is_dir():
|
||||
print(f"Error: landing dir does not exist: {args.landing_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
download_nuts_boundaries(args.landing_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
553
scripts/stripe_e2e_checkout_test.py
Normal file
553
scripts/stripe_e2e_checkout_test.py
Normal file
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
E2E test for checkout.session.completed webhook → transaction.completed handler.
|
||||
|
||||
Tests credit packs, sticky boosts, and business plan PDF purchases by:
|
||||
1. Constructing realistic checkout.session.completed payloads with our real price IDs
|
||||
2. Signing them with the active webhook secret
|
||||
3. POSTing to the running dev server
|
||||
4. Verifying DB state changes (credit_balance, supplier_boosts, business_plan_exports)
|
||||
|
||||
Prerequisites:
|
||||
- ngrok + webhook endpoint registered (stripe_e2e_setup.py)
|
||||
- Dev server running with webhook secret loaded
|
||||
- Stripe products synced (setup_stripe --sync)
|
||||
|
||||
Run: uv run python scripts/stripe_e2e_checkout_test.py
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
||||
WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
||||
SERVER_URL = "http://localhost:5000"
|
||||
WEBHOOK_URL = f"{SERVER_URL}/billing/webhook/stripe"
|
||||
|
||||
assert WEBHOOK_SECRET, "STRIPE_WEBHOOK_SECRET not set — run stripe_e2e_setup.py"
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
|
||||
def ok(msg):
|
||||
global passed
|
||||
passed += 1
|
||||
print(f" \u2713 {msg}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
global failed
|
||||
failed += 1
|
||||
errors.append(msg)
|
||||
print(f" \u2717 {msg}")
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f"\n{'─' * 60}")
|
||||
print(f" {title}")
|
||||
print(f"{'─' * 60}")
|
||||
|
||||
|
||||
def query_db(sql, params=()):
|
||||
conn = sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
return [dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sign_stripe_payload(payload_bytes: bytes, secret: str) -> str:
|
||||
"""Create a valid Stripe-Signature header."""
|
||||
timestamp = str(int(time.time()))
|
||||
signed_payload = f"{timestamp}.{payload_bytes.decode()}"
|
||||
sig = hmac.new(
|
||||
secret.encode(), signed_payload.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"t={timestamp},v1={sig}"
|
||||
|
||||
|
||||
def post_webhook(event_type: str, obj: dict) -> int:
|
||||
"""Post a signed webhook to the server. Returns HTTP status code."""
|
||||
payload = json.dumps({
|
||||
"id": f"evt_test_{int(time.time()*1000)}",
|
||||
"type": event_type,
|
||||
"data": {"object": obj},
|
||||
}).encode()
|
||||
|
||||
sig = sign_stripe_payload(payload, WEBHOOK_SECRET)
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", f"Stripe-Signature: {sig}",
|
||||
"--data-binary", "@-",
|
||||
WEBHOOK_URL],
|
||||
input=payload.decode(), capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
|
||||
|
||||
# ─── Preflight ────────────────────────────────────────────
|
||||
|
||||
section("Preflight")
|
||||
|
||||
# Server up
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
assert result.stdout.strip() in ("200", "301"), f"Server down ({result.stdout})"
|
||||
ok("Dev server running")
|
||||
|
||||
# Webhook active
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST", "-H", "Content-Type: application/json", "-d", "{}",
|
||||
WEBHOOK_URL],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
assert result.stdout.strip() == "400", f"Webhook returns {result.stdout} (expected 400)"
|
||||
ok("Webhook signature check active")
|
||||
|
||||
# Load price IDs
|
||||
products = query_db("SELECT key, provider_price_id FROM payment_products WHERE provider = 'stripe'")
|
||||
price_map = {p["key"]: p["provider_price_id"] for p in products}
|
||||
ok(f"Loaded {len(price_map)} products")
|
||||
|
||||
# Test data
|
||||
users = query_db("SELECT id, email FROM users LIMIT 5")
|
||||
test_user = users[0]
|
||||
ok(f"User: {test_user['email']} (id={test_user['id']})")
|
||||
|
||||
suppliers = query_db("SELECT id, name, credit_balance FROM suppliers WHERE claimed_by IS NOT NULL LIMIT 1")
|
||||
assert suppliers, "No claimed supplier found"
|
||||
test_supplier = suppliers[0]
|
||||
initial_balance = test_supplier["credit_balance"]
|
||||
ok(f"Supplier: {test_supplier['name']} (id={test_supplier['id']}, balance={initial_balance})")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Test 1: Credit Pack purchases (all 4 sizes)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("1. Credit Pack purchases via checkout.session.completed")
|
||||
|
||||
credit_packs = [
|
||||
("credits_25", 25),
|
||||
("credits_50", 50),
|
||||
("credits_100", 100),
|
||||
("credits_250", 250),
|
||||
]
|
||||
|
||||
running_balance = initial_balance
|
||||
|
||||
for key, amount in credit_packs:
|
||||
price_id = price_map.get(key)
|
||||
if not price_id:
|
||||
fail(f"{key}: price not found")
|
||||
continue
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": f"cs_test_{key}_{int(time.time())}",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_credits",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": key,
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
|
||||
})
|
||||
|
||||
if status == 200:
|
||||
ok(f"{key}: webhook accepted (HTTP 200)")
|
||||
else:
|
||||
fail(f"{key}: webhook returned HTTP {status}")
|
||||
continue
|
||||
|
||||
# Wait and check balance
|
||||
time.sleep(2)
|
||||
rows = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))
|
||||
new_balance = rows[0]["credit_balance"] if rows else -1
|
||||
expected = running_balance + amount
|
||||
|
||||
if new_balance == expected:
|
||||
ok(f"{key}: balance {running_balance} → {new_balance} (+{amount})")
|
||||
running_balance = new_balance
|
||||
else:
|
||||
fail(f"{key}: balance {new_balance}, expected {expected}")
|
||||
running_balance = new_balance # update anyway for next test
|
||||
|
||||
# Check ledger entries
|
||||
ledger = query_db(
|
||||
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'pack_purchase' ORDER BY id DESC LIMIT 4",
|
||||
(test_supplier["id"],),
|
||||
)
|
||||
if len(ledger) >= 4:
|
||||
ok(f"Credit ledger: {len(ledger)} pack_purchase entries")
|
||||
else:
|
||||
fail(f"Credit ledger: only {len(ledger)} entries (expected 4)")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Test 2: Sticky Boost purchases
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("2. Sticky boost purchases")
|
||||
|
||||
# 2a. Sticky Week
|
||||
price_id = price_map.get("boost_sticky_week")
|
||||
if price_id:
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": f"cs_test_sticky_week_{int(time.time())}",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_sticky",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "boost_sticky_week",
|
||||
"sticky_country": "DE",
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
|
||||
})
|
||||
|
||||
if status == 200:
|
||||
ok("boost_sticky_week: webhook accepted")
|
||||
else:
|
||||
fail(f"boost_sticky_week: HTTP {status}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Check supplier_boosts
|
||||
boosts = query_db(
|
||||
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND boost_type = 'sticky_week' ORDER BY id DESC LIMIT 1",
|
||||
(test_supplier["id"],),
|
||||
)
|
||||
if boosts:
|
||||
b = boosts[0]
|
||||
ok(f"supplier_boosts row: type=sticky_week, status={b['status']}")
|
||||
if b.get("expires_at"):
|
||||
ok(f"expires_at set: {b['expires_at']}")
|
||||
else:
|
||||
fail("expires_at is NULL")
|
||||
else:
|
||||
fail("No supplier_boosts row for sticky_week")
|
||||
|
||||
# Check suppliers.sticky_until
|
||||
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
|
||||
if sup and sup[0]["sticky_until"]:
|
||||
ok(f"sticky_until set: {sup[0]['sticky_until']}")
|
||||
else:
|
||||
fail("sticky_until not set")
|
||||
if sup and sup[0]["sticky_country"] == "DE":
|
||||
ok("sticky_country=DE")
|
||||
else:
|
||||
fail(f"sticky_country={sup[0]['sticky_country'] if sup else '?'}")
|
||||
else:
|
||||
fail("boost_sticky_week price not found")
|
||||
|
||||
# 2b. Sticky Month
|
||||
price_id = price_map.get("boost_sticky_month")
|
||||
if price_id:
|
||||
# Reset sticky fields
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.execute("UPDATE suppliers SET sticky_until=NULL, sticky_country=NULL WHERE id=?", (test_supplier["id"],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": f"cs_test_sticky_month_{int(time.time())}",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_sticky",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "boost_sticky_month",
|
||||
"sticky_country": "ES",
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
|
||||
})
|
||||
|
||||
if status == 200:
|
||||
ok("boost_sticky_month: webhook accepted")
|
||||
else:
|
||||
fail(f"boost_sticky_month: HTTP {status}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
boosts = query_db(
|
||||
"SELECT * FROM supplier_boosts WHERE supplier_id = ? AND boost_type = 'sticky_month' ORDER BY id DESC LIMIT 1",
|
||||
(test_supplier["id"],),
|
||||
)
|
||||
if boosts:
|
||||
ok(f"supplier_boosts row: type=sticky_month, expires_at={boosts[0].get('expires_at', '?')[:10]}")
|
||||
else:
|
||||
fail("No supplier_boosts row for sticky_month")
|
||||
|
||||
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
|
||||
if sup and sup[0]["sticky_country"] == "ES":
|
||||
ok("sticky_country=ES (month)")
|
||||
else:
|
||||
fail(f"sticky_country wrong: {sup[0] if sup else '?'}")
|
||||
else:
|
||||
fail("boost_sticky_month price not found")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Test 3: Business Plan PDF purchase
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("3. Business Plan PDF purchase")
|
||||
|
||||
price_id = price_map.get("business_plan")
|
||||
if price_id:
|
||||
# Create a scenario for the user first
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.execute(
|
||||
"INSERT INTO scenarios (user_id, name, state_json, created_at) VALUES (?, 'Test', '{}', datetime('now'))",
|
||||
(test_user["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
scenario_row = conn.execute("SELECT id FROM scenarios WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(test_user["id"],)).fetchone()
|
||||
scenario_id = scenario_row[0] if scenario_row else 0
|
||||
conn.close()
|
||||
ok(f"Created test scenario: id={scenario_id}")
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": f"cs_test_bp_{int(time.time())}",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_bp",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"plan": "business_plan",
|
||||
"scenario_id": str(scenario_id),
|
||||
"language": "de",
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
|
||||
})
|
||||
|
||||
if status == 200:
|
||||
ok("business_plan: webhook accepted")
|
||||
else:
|
||||
fail(f"business_plan: HTTP {status}")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# Check business_plan_exports
|
||||
exports = query_db(
|
||||
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(test_user["id"],),
|
||||
)
|
||||
if exports:
|
||||
e = exports[0]
|
||||
ok(f"Export row: status={e['status']}, language={e['language']}")
|
||||
if e["status"] == "pending":
|
||||
ok("Status: pending (waiting for worker)")
|
||||
else:
|
||||
print(f" ? Status: {e['status']} (expected pending)")
|
||||
if e["language"] == "de":
|
||||
ok("Language: de")
|
||||
else:
|
||||
fail(f"Language: {e['language']} (expected de)")
|
||||
if e.get("token"):
|
||||
ok(f"Download token generated: {e['token'][:10]}...")
|
||||
else:
|
||||
fail("No download token")
|
||||
if e.get("scenario_id") == scenario_id:
|
||||
ok(f"Scenario ID matches: {scenario_id}")
|
||||
else:
|
||||
fail(f"Scenario ID: {e.get('scenario_id')} (expected {scenario_id})")
|
||||
else:
|
||||
fail("No business_plan_exports row created")
|
||||
else:
|
||||
fail("business_plan price not found")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Test 4: Edge cases
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("4a. Edge: checkout.session.completed with unknown price_id")
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": "cs_test_unknown",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_unknown",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "nonexistent_product",
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": "price_nonexistent"}, "quantity": 1}]},
|
||||
})
|
||||
ok(f"Unknown price: HTTP {status} (no crash)") if status == 200 else fail(f"Unknown price: HTTP {status}")
|
||||
|
||||
# Server alive?
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ok("Server alive after unknown price") if result.stdout.strip() in ("200", "301") else fail("Server crashed!")
|
||||
|
||||
|
||||
section("4b. Edge: checkout.session.completed with missing supplier_id (credit pack)")
|
||||
|
||||
balance_before = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))[0]["credit_balance"]
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": "cs_test_no_supplier",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_nosup",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
# NO supplier_id
|
||||
"plan": "credits_25",
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": price_map["credits_25"]}, "quantity": 1}]},
|
||||
})
|
||||
ok(f"Missing supplier_id: HTTP {status} (no crash)") if status == 200 else fail(f"HTTP {status}")
|
||||
|
||||
time.sleep(1)
|
||||
balance_after = query_db("SELECT credit_balance FROM suppliers WHERE id = ?", (test_supplier["id"],))[0]["credit_balance"]
|
||||
if balance_after == balance_before:
|
||||
ok("Balance unchanged (correctly skipped — no supplier_id)")
|
||||
else:
|
||||
fail(f"Balance changed: {balance_before} → {balance_after}")
|
||||
|
||||
|
||||
section("4c. Edge: checkout.session.completed with missing metadata")
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": "cs_test_no_meta",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_nometa",
|
||||
"metadata": {},
|
||||
})
|
||||
ok(f"Empty metadata: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"{SERVER_URL}/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ok("Server alive after empty metadata") if result.stdout.strip() in ("200", "301") else fail("Server crashed!")
|
||||
|
||||
|
||||
section("4d. Edge: subscription mode checkout (not payment)")
|
||||
|
||||
# checkout.session.completed with mode=subscription should create a subscription
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": "cs_test_sub_mode",
|
||||
"mode": "subscription",
|
||||
"customer": "cus_test_submode",
|
||||
"subscription": "sub_from_checkout_123",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"plan": "starter",
|
||||
},
|
||||
})
|
||||
ok(f"Subscription-mode checkout: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
|
||||
|
||||
# Note: this fires subscription.activated, but since we can't mock the Stripe API call
|
||||
# to fetch the subscription, it will log a warning and continue. That's fine.
|
||||
|
||||
|
||||
section("4e. Edge: sticky boost without sticky_country in metadata")
|
||||
|
||||
price_id = price_map.get("boost_sticky_week")
|
||||
if price_id:
|
||||
# Reset sticky fields
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.execute("UPDATE suppliers SET sticky_until=NULL, sticky_country=NULL WHERE id=?", (test_supplier["id"],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
status = post_webhook("checkout.session.completed", {
|
||||
"id": f"cs_test_no_country_{int(time.time())}",
|
||||
"mode": "payment",
|
||||
"customer": "cus_test_nocountry",
|
||||
"metadata": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "boost_sticky_week",
|
||||
# NO sticky_country
|
||||
},
|
||||
"line_items": {"data": [{"price": {"id": price_id}, "quantity": 1}]},
|
||||
})
|
||||
ok(f"Missing sticky_country: HTTP {status}") if status == 200 else fail(f"HTTP {status}")
|
||||
|
||||
time.sleep(2)
|
||||
sup = query_db("SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?", (test_supplier["id"],))
|
||||
if sup and sup[0]["sticky_until"]:
|
||||
ok(f"sticky_until still set (country defaults to empty: '{sup[0]['sticky_country']}')")
|
||||
else:
|
||||
fail("sticky boost not created without country")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Test 5: Use stripe trigger for a real checkout.session.completed
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("5. stripe trigger checkout.session.completed (real Stripe event)")
|
||||
|
||||
print(" Triggering real checkout.session.completed via Stripe CLI...")
|
||||
result = subprocess.run(
|
||||
["stripe", "trigger", "checkout.session.completed"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ok("stripe trigger succeeded")
|
||||
# Wait for webhook delivery via ngrok
|
||||
time.sleep(5)
|
||||
|
||||
# Check ngrok for the delivery
|
||||
import urllib.request
|
||||
try:
|
||||
resp = urllib.request.urlopen("http://localhost:4040/api/requests/http?limit=5", timeout=5)
|
||||
reqs = json.loads(resp.read())
|
||||
recent_webhooks = [
|
||||
r for r in reqs.get("requests", [])
|
||||
if r.get("request", {}).get("uri") == "/billing/webhook/stripe"
|
||||
]
|
||||
if recent_webhooks:
|
||||
latest = recent_webhooks[0]
|
||||
http_status = latest.get("response", {}).get("status_code")
|
||||
ok(f"Webhook delivered via ngrok: HTTP {http_status}")
|
||||
else:
|
||||
print(" (no webhook seen in ngrok — may have been delivered before log window)")
|
||||
ok("stripe trigger completed (webhook delivery not verified)")
|
||||
except Exception:
|
||||
ok("stripe trigger completed (ngrok API unavailable for verification)")
|
||||
else:
|
||||
fail(f"stripe trigger failed: {result.stderr[:100]}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("RESULTS")
|
||||
|
||||
total = passed + failed
|
||||
print(f"\n {passed}/{total} passed, {failed} failed\n")
|
||||
|
||||
if errors:
|
||||
print(" Failures:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
print()
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
124
scripts/stripe_e2e_setup.py
Normal file
124
scripts/stripe_e2e_setup.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Step 1: Register a Stripe webhook endpoint via ngrok and update .env.
|
||||
|
||||
Run BEFORE starting the dev server:
|
||||
1. Start ngrok: ngrok http 5000
|
||||
2. Run this script: uv run python scripts/stripe_e2e_setup.py
|
||||
3. Start dev server: make dev
|
||||
4. Run E2E tests: uv run python scripts/stripe_e2e_test.py
|
||||
|
||||
To tear down afterward:
|
||||
uv run python scripts/stripe_e2e_setup.py --teardown
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import stripe
|
||||
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
|
||||
if not STRIPE_SECRET_KEY:
|
||||
print("ERROR: Set STRIPE_SECRET_KEY or STRIPE_API_PRIVATE_KEY in .env")
|
||||
sys.exit(1)
|
||||
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
stripe.max_network_retries = 2
|
||||
|
||||
ENV_PATH = os.path.join(os.path.dirname(__file__), "..", ".env")
|
||||
ENV_PATH = os.path.abspath(ENV_PATH)
|
||||
WEBHOOK_PATH = "/billing/webhook/stripe"
|
||||
NGROK_API = "http://localhost:4040/api/tunnels"
|
||||
|
||||
|
||||
def _update_env(key, value):
|
||||
"""Update a key in .env file."""
|
||||
text = open(ENV_PATH).read()
|
||||
pattern = rf"^{key}=.*$"
|
||||
replacement = f"{key}={value}"
|
||||
if re.search(pattern, text, re.MULTILINE):
|
||||
text = re.sub(pattern, replacement, text, flags=re.MULTILINE)
|
||||
else:
|
||||
text = text.rstrip("\n") + f"\n{replacement}\n"
|
||||
open(ENV_PATH, "w").write(text)
|
||||
|
||||
|
||||
def setup():
|
||||
# Get ngrok tunnel URL
|
||||
try:
|
||||
resp = urllib.request.urlopen(NGROK_API, timeout=5)
|
||||
tunnels = json.loads(resp.read())
|
||||
tunnel_url = tunnels["tunnels"][0]["public_url"]
|
||||
except Exception as e:
|
||||
print(f"ERROR: ngrok not running: {e}")
|
||||
print("Start ngrok first: ngrok http 5000")
|
||||
sys.exit(1)
|
||||
|
||||
webhook_url = f"{tunnel_url}{WEBHOOK_PATH}"
|
||||
print(f"ngrok tunnel: {tunnel_url}")
|
||||
print(f"Webhook URL: {webhook_url}")
|
||||
|
||||
# Check for existing E2E webhook endpoint
|
||||
existing_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
|
||||
if existing_id:
|
||||
try:
|
||||
ep = stripe.WebhookEndpoint.retrieve(existing_id)
|
||||
if ep.url == webhook_url and ep.status == "enabled":
|
||||
print(f"\nEndpoint already exists and matches: {existing_id}")
|
||||
print("Ready to test. Run: uv run python scripts/stripe_e2e_test.py")
|
||||
return
|
||||
# URL changed (new ngrok session), delete and recreate
|
||||
print(f"Existing endpoint URL mismatch, recreating...")
|
||||
stripe.WebhookEndpoint.delete(existing_id)
|
||||
except stripe.InvalidRequestError:
|
||||
pass # Already deleted
|
||||
|
||||
# Create webhook endpoint
|
||||
endpoint = stripe.WebhookEndpoint.create(
|
||||
url=webhook_url,
|
||||
enabled_events=[
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"invoice.payment_failed",
|
||||
],
|
||||
)
|
||||
|
||||
print(f"\nCreated endpoint: {endpoint.id}")
|
||||
print(f"Webhook secret: {endpoint.secret[:25]}...")
|
||||
|
||||
# Update .env
|
||||
_update_env("STRIPE_WEBHOOK_SECRET", endpoint.secret)
|
||||
_update_env("STRIPE_WEBHOOK_ENDPOINT_ID", endpoint.id)
|
||||
print("\nUpdated .env with STRIPE_WEBHOOK_SECRET and STRIPE_WEBHOOK_ENDPOINT_ID")
|
||||
print("\nNext steps:")
|
||||
print(" 1. Restart dev server: make dev")
|
||||
print(" 2. Run E2E tests: uv run python scripts/stripe_e2e_test.py")
|
||||
|
||||
|
||||
def teardown():
|
||||
endpoint_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
|
||||
if endpoint_id:
|
||||
try:
|
||||
stripe.WebhookEndpoint.delete(endpoint_id)
|
||||
print(f"Deleted webhook endpoint: {endpoint_id}")
|
||||
except stripe.InvalidRequestError:
|
||||
print(f"Endpoint {endpoint_id} already deleted")
|
||||
|
||||
_update_env("STRIPE_WEBHOOK_SECRET", "")
|
||||
_update_env("STRIPE_WEBHOOK_ENDPOINT_ID", "")
|
||||
print("Cleared .env webhook config")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--teardown" in sys.argv:
|
||||
teardown()
|
||||
else:
|
||||
setup()
|
||||
727
scripts/stripe_e2e_test.py
Normal file
727
scripts/stripe_e2e_test.py
Normal file
@@ -0,0 +1,727 @@
|
||||
"""
|
||||
Comprehensive Stripe E2E Tests — real webhooks via ngrok.
|
||||
|
||||
Tests every product type, subscription lifecycle, payment failures,
|
||||
and edge cases against a running dev server with real Stripe webhooks.
|
||||
|
||||
Prerequisites:
|
||||
1. ngrok http 5000
|
||||
2. uv run python scripts/stripe_e2e_setup.py
|
||||
3. make dev (or restart after setup)
|
||||
4. uv run python scripts/stripe_e2e_test.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
import stripe
|
||||
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
|
||||
assert STRIPE_SECRET_KEY, "Set STRIPE_SECRET_KEY or STRIPE_API_PRIVATE_KEY in .env"
|
||||
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
stripe.max_network_retries = 2
|
||||
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db")
|
||||
MAX_WAIT_SECONDS = 20
|
||||
POLL_SECONDS = 0.5
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
cleanup_sub_ids = []
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
def ok(msg):
|
||||
global passed
|
||||
passed += 1
|
||||
print(f" \u2713 {msg}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
global failed
|
||||
failed += 1
|
||||
errors.append(msg)
|
||||
print(f" \u2717 {msg}")
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f"\n{'─' * 60}")
|
||||
print(f" {title}")
|
||||
print(f"{'─' * 60}")
|
||||
|
||||
|
||||
def query_db(sql, params=()):
|
||||
conn = sqlite3.connect(f"file:{DATABASE_PATH}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
return [dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def wait_for_row(sql, params=(), timeout_seconds=MAX_WAIT_SECONDS):
|
||||
"""Poll until query returns at least one row."""
|
||||
deadline = time.time() + timeout_seconds
|
||||
while time.time() < deadline:
|
||||
rows = query_db(sql, params)
|
||||
if rows:
|
||||
return rows
|
||||
time.sleep(POLL_SECONDS)
|
||||
return []
|
||||
|
||||
|
||||
def wait_for_value(sql, params, column, expected, timeout_seconds=MAX_WAIT_SECONDS):
|
||||
"""Poll until column == expected."""
|
||||
deadline = time.time() + timeout_seconds
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
rows = query_db(sql, params)
|
||||
if rows:
|
||||
last = rows[0]
|
||||
if last[column] == expected:
|
||||
return last
|
||||
time.sleep(POLL_SECONDS)
|
||||
return last
|
||||
|
||||
|
||||
def get_or_create_customer(email, name):
|
||||
existing = stripe.Customer.list(email=email, limit=1)
|
||||
if existing.data:
|
||||
return existing.data[0]
|
||||
return stripe.Customer.create(email=email, name=name, metadata={"e2e": "true"})
|
||||
|
||||
|
||||
_pm_cache = {}
|
||||
|
||||
def attach_pm(customer_id):
|
||||
"""Create a fresh test Visa and attach it."""
|
||||
if customer_id in _pm_cache:
|
||||
return _pm_cache[customer_id]
|
||||
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
|
||||
stripe.PaymentMethod.attach(pm.id, customer=customer_id)
|
||||
stripe.Customer.modify(customer_id, invoice_settings={"default_payment_method": pm.id})
|
||||
_pm_cache[customer_id] = pm.id
|
||||
return pm.id
|
||||
|
||||
|
||||
def create_sub(customer_id, price_id, metadata, pm_id):
|
||||
"""Create subscription and track for cleanup."""
|
||||
sub = stripe.Subscription.create(
|
||||
customer=customer_id,
|
||||
items=[{"price": price_id}],
|
||||
metadata=metadata,
|
||||
default_payment_method=pm_id,
|
||||
)
|
||||
cleanup_sub_ids.append(sub.id)
|
||||
return sub
|
||||
|
||||
|
||||
def cancel_sub(sub_id):
|
||||
try:
|
||||
stripe.Subscription.cancel(sub_id)
|
||||
except stripe.InvalidRequestError:
|
||||
pass
|
||||
|
||||
|
||||
# ─── Preflight ────────────────────────────────────────────
|
||||
|
||||
section("Preflight")
|
||||
|
||||
# Dev server
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
assert result.stdout.strip() in ("200", "301", "302"), f"Dev server down (HTTP {result.stdout.strip()})"
|
||||
ok("Dev server running")
|
||||
|
||||
# Webhook endpoint
|
||||
endpoint_id = os.getenv("STRIPE_WEBHOOK_ENDPOINT_ID", "")
|
||||
assert endpoint_id, "STRIPE_WEBHOOK_ENDPOINT_ID not set — run stripe_e2e_setup.py"
|
||||
ep = stripe.WebhookEndpoint.retrieve(endpoint_id)
|
||||
assert ep.status == "enabled", f"Endpoint status: {ep.status}"
|
||||
ok(f"Webhook endpoint: {ep.url}")
|
||||
|
||||
# Webhook secret loaded in server
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST", "-H", "Content-Type: application/json",
|
||||
"-d", "{}", "http://localhost:5000/billing/webhook/stripe"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
assert result.stdout.strip() == "400", f"Webhook returns {result.stdout.strip()} (need 400 = sig check active)"
|
||||
ok("Webhook signature verification active")
|
||||
|
||||
# Price map
|
||||
products = query_db("SELECT key, provider_price_id, billing_type FROM payment_products WHERE provider = 'stripe'")
|
||||
price_map = {p["key"]: p for p in products}
|
||||
assert len(price_map) >= 17, f"Only {len(price_map)} products"
|
||||
ok(f"{len(price_map)} Stripe products loaded")
|
||||
|
||||
# Test data
|
||||
users = query_db("SELECT id, email FROM users LIMIT 10")
|
||||
assert users
|
||||
test_user = users[0]
|
||||
ok(f"User: {test_user['email']} (id={test_user['id']})")
|
||||
|
||||
suppliers = query_db("SELECT id, name, claimed_by, credit_balance, tier FROM suppliers LIMIT 5")
|
||||
assert suppliers
|
||||
# Pick a supplier with claimed_by set (has an owner user)
|
||||
test_supplier = next((s for s in suppliers if s["claimed_by"]), suppliers[0])
|
||||
supplier_user_id = test_supplier["claimed_by"] or test_user["id"]
|
||||
ok(f"Supplier: {test_supplier['name']} (id={test_supplier['id']}, owner={supplier_user_id})")
|
||||
|
||||
# Record initial supplier state for later comparison
|
||||
initial_credit_balance = test_supplier["credit_balance"]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 1. PLANNER SUBSCRIPTIONS
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("1a. Planner Starter — create → verify DB → cancel → verify cancelled")
|
||||
|
||||
cus_starter = get_or_create_customer("e2e-starter@sandbox.padelnomics.com", "E2E Starter")
|
||||
pm_starter = attach_pm(cus_starter.id)
|
||||
|
||||
sub = create_sub(cus_starter.id, price_map["starter"]["provider_price_id"],
|
||||
{"user_id": str(test_user["id"]), "plan": "starter"}, pm_starter)
|
||||
ok(f"Created: {sub.id} (status={sub.status})")
|
||||
|
||||
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
|
||||
if rows:
|
||||
r = rows[0]
|
||||
ok(f"DB: plan={r['plan']}, status={r['status']}") if r["plan"] == "starter" and r["status"] == "active" else fail(f"DB: plan={r['plan']}, status={r['status']}")
|
||||
if r.get("current_period_end"):
|
||||
ok(f"period_end set: {r['current_period_end'][:10]}")
|
||||
else:
|
||||
fail("period_end is NULL")
|
||||
else:
|
||||
fail("Subscription NOT in DB")
|
||||
|
||||
# billing_customers
|
||||
bc = query_db("SELECT * FROM billing_customers WHERE user_id = ?", (test_user["id"],))
|
||||
ok("billing_customers created") if bc else fail("billing_customers NOT created")
|
||||
|
||||
# Cancel
|
||||
cancel_sub(sub.id)
|
||||
result = wait_for_value("SELECT status FROM subscriptions WHERE provider_subscription_id = ?",
|
||||
(sub.id,), "status", "cancelled")
|
||||
ok("Status → cancelled") if result and result["status"] == "cancelled" else fail(f"Status: {result['status'] if result else '?'}")
|
||||
|
||||
|
||||
section("1b. Planner Pro — subscription lifecycle")
|
||||
|
||||
pro_user = users[1] if len(users) > 1 else users[0]
|
||||
cus_pro = get_or_create_customer("e2e-pro@sandbox.padelnomics.com", "E2E Pro")
|
||||
pm_pro = attach_pm(cus_pro.id)
|
||||
|
||||
sub = create_sub(cus_pro.id, price_map["pro"]["provider_price_id"],
|
||||
{"user_id": str(pro_user["id"]), "plan": "pro"}, pm_pro)
|
||||
ok(f"Created: {sub.id}")
|
||||
|
||||
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
|
||||
if rows and rows[0]["plan"] == "pro" and rows[0]["status"] == "active":
|
||||
ok("DB: plan=pro, status=active")
|
||||
else:
|
||||
fail(f"DB: {rows[0] if rows else 'not found'}")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
ok("Cleaned up")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 2. SUPPLIER SUBSCRIPTIONS (all 4 variants)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("2a. Supplier Growth (monthly) — tier, credits, verified")
|
||||
|
||||
cus_sup = get_or_create_customer("e2e-supplier@sandbox.padelnomics.com", "E2E Supplier")
|
||||
pm_sup = attach_pm(cus_sup.id)
|
||||
|
||||
sub = create_sub(cus_sup.id, price_map["supplier_growth"]["provider_price_id"], {
|
||||
"user_id": str(supplier_user_id),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "supplier_growth",
|
||||
}, pm_sup)
|
||||
ok(f"Created: {sub.id}")
|
||||
|
||||
result = wait_for_value(
|
||||
"SELECT tier, is_verified, monthly_credits, credit_balance FROM suppliers WHERE id = ?",
|
||||
(test_supplier["id"],), "tier", "growth",
|
||||
)
|
||||
if result:
|
||||
ok("tier=growth") if result["tier"] == "growth" else fail(f"tier={result['tier']}")
|
||||
ok("is_verified=1") if result["is_verified"] == 1 else fail(f"is_verified={result['is_verified']}")
|
||||
ok("monthly_credits=30") if result["monthly_credits"] == 30 else fail(f"monthly_credits={result['monthly_credits']}")
|
||||
ok(f"credit_balance={result['credit_balance']}") if result["credit_balance"] >= 30 else fail(f"credit_balance={result['credit_balance']}")
|
||||
else:
|
||||
fail("Tier not updated")
|
||||
|
||||
# Check credit ledger entry was created
|
||||
ledger = query_db(
|
||||
"SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'monthly_allocation' ORDER BY id DESC LIMIT 1",
|
||||
(test_supplier["id"],),
|
||||
)
|
||||
ok("Credit ledger entry created") if ledger else fail("No credit ledger entry")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
ok("Cleaned up")
|
||||
|
||||
|
||||
section("2b. Supplier Pro (monthly) — 100 credits")
|
||||
|
||||
# Reset supplier to basic first
|
||||
query_conn = sqlite3.connect(DATABASE_PATH)
|
||||
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
|
||||
(test_supplier["id"],))
|
||||
query_conn.commit()
|
||||
query_conn.close()
|
||||
time.sleep(1)
|
||||
|
||||
sub = create_sub(cus_sup.id, price_map["supplier_pro"]["provider_price_id"], {
|
||||
"user_id": str(supplier_user_id),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "supplier_pro",
|
||||
}, pm_sup)
|
||||
ok(f"Created: {sub.id}")
|
||||
|
||||
result = wait_for_value(
|
||||
"SELECT tier, monthly_credits, credit_balance FROM suppliers WHERE id = ?",
|
||||
(test_supplier["id"],), "tier", "pro",
|
||||
)
|
||||
if result:
|
||||
ok("tier=pro") if result["tier"] == "pro" else fail(f"tier={result['tier']}")
|
||||
ok("monthly_credits=100") if result["monthly_credits"] == 100 else fail(f"monthly_credits={result['monthly_credits']}")
|
||||
ok(f"credit_balance={result['credit_balance']}") if result["credit_balance"] >= 100 else fail(f"credit_balance={result['credit_balance']}")
|
||||
else:
|
||||
fail("Tier not updated to pro")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
ok("Cleaned up")
|
||||
|
||||
|
||||
section("2c. Supplier Growth (yearly)")
|
||||
|
||||
# Reset
|
||||
query_conn = sqlite3.connect(DATABASE_PATH)
|
||||
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
|
||||
(test_supplier["id"],))
|
||||
query_conn.commit()
|
||||
query_conn.close()
|
||||
time.sleep(1)
|
||||
|
||||
sub = create_sub(cus_sup.id, price_map["supplier_growth_yearly"]["provider_price_id"], {
|
||||
"user_id": str(supplier_user_id),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "supplier_growth_yearly",
|
||||
}, pm_sup)
|
||||
ok(f"Created: {sub.id}")
|
||||
|
||||
result = wait_for_value(
|
||||
"SELECT tier, monthly_credits FROM suppliers WHERE id = ?",
|
||||
(test_supplier["id"],), "tier", "growth",
|
||||
)
|
||||
if result:
|
||||
ok("tier=growth (yearly maps to growth)")
|
||||
ok("monthly_credits=30") if result["monthly_credits"] == 30 else fail(f"monthly_credits={result['monthly_credits']}")
|
||||
else:
|
||||
fail("Yearly growth not processed")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
ok("Cleaned up")
|
||||
|
||||
|
||||
section("2d. Supplier Pro (yearly)")
|
||||
|
||||
query_conn = sqlite3.connect(DATABASE_PATH)
|
||||
query_conn.execute("UPDATE suppliers SET tier='free', monthly_credits=0, credit_balance=0, is_verified=0 WHERE id=?",
|
||||
(test_supplier["id"],))
|
||||
query_conn.commit()
|
||||
query_conn.close()
|
||||
time.sleep(1)
|
||||
|
||||
sub = create_sub(cus_sup.id, price_map["supplier_pro_yearly"]["provider_price_id"], {
|
||||
"user_id": str(supplier_user_id),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": "supplier_pro_yearly",
|
||||
}, pm_sup)
|
||||
ok(f"Created: {sub.id}")
|
||||
|
||||
result = wait_for_value(
|
||||
"SELECT tier, monthly_credits FROM suppliers WHERE id = ?",
|
||||
(test_supplier["id"],), "tier", "pro",
|
||||
)
|
||||
if result:
|
||||
ok("tier=pro (yearly maps to pro)")
|
||||
ok("monthly_credits=100") if result["monthly_credits"] == 100 else fail(f"monthly_credits={result['monthly_credits']}")
|
||||
else:
|
||||
fail("Yearly pro not processed")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
ok("Cleaned up")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3. BOOST ADD-ON SUBSCRIPTIONS (all 4)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("3. Boost add-on subscriptions (Logo, Highlight, Verified, Card Color)")
|
||||
|
||||
cus_boost = get_or_create_customer("e2e-boost@sandbox.padelnomics.com", "E2E Boost")
|
||||
pm_boost = attach_pm(cus_boost.id)
|
||||
|
||||
boost_keys = ["boost_logo", "boost_highlight", "boost_verified", "boost_card_color"]
|
||||
for key in boost_keys:
|
||||
price_id = price_map[key]["provider_price_id"]
|
||||
sub = create_sub(cus_boost.id, price_id, {
|
||||
"user_id": str(supplier_user_id),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": key,
|
||||
}, pm_boost)
|
||||
ok(f"{key}: {sub.id} (active)")
|
||||
# Let webhook arrive
|
||||
time.sleep(2)
|
||||
cancel_sub(sub.id)
|
||||
|
||||
# Boosts with plan starting "boost_" don't hit supplier handler (only supplier_ plans do).
|
||||
# They go through the user subscription path. Verify at least the webhooks were accepted.
|
||||
# Check ngrok logs for 200s
|
||||
import json
|
||||
import urllib.request
|
||||
try:
|
||||
resp = urllib.request.urlopen("http://localhost:4040/api/requests/http?limit=50", timeout=5)
|
||||
requests_data = json.loads(resp.read())
|
||||
webhook_200s = sum(1 for r in requests_data.get("requests", [])
|
||||
if r.get("request", {}).get("uri") == "/billing/webhook/stripe"
|
||||
and r.get("response", {}).get("status_code") == 200)
|
||||
ok(f"Webhook 200 responses seen: {webhook_200s}")
|
||||
except Exception:
|
||||
print(" (could not verify ngrok logs)")
|
||||
|
||||
ok("All 4 boost add-ons tested")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 4. CHECKOUT SESSIONS — every product
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("4. Checkout session creation (all 17 products)")
|
||||
|
||||
try:
|
||||
ngrok_resp = urllib.request.urlopen("http://localhost:4040/api/tunnels", timeout=5)
|
||||
tunnel_url = json.loads(ngrok_resp.read())["tunnels"][0]["public_url"]
|
||||
except Exception:
|
||||
tunnel_url = "http://localhost:5000"
|
||||
|
||||
checkout_ok = 0
|
||||
for key, p in sorted(price_map.items()):
|
||||
mode = "subscription" if p["billing_type"] == "subscription" else "payment"
|
||||
try:
|
||||
stripe.checkout.Session.create(
|
||||
mode=mode,
|
||||
customer=cus_starter.id,
|
||||
line_items=[{"price": p["provider_price_id"], "quantity": 1}],
|
||||
metadata={"user_id": str(test_user["id"]), "plan": key, "test": "true"},
|
||||
success_url=f"{tunnel_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{tunnel_url}/billing/pricing",
|
||||
)
|
||||
checkout_ok += 1
|
||||
except stripe.StripeError as e:
|
||||
fail(f"Checkout failed: {key} -> {e}")
|
||||
|
||||
if checkout_ok == len(price_map):
|
||||
ok(f"All {checkout_ok} checkout sessions created")
|
||||
else:
|
||||
fail(f"{len(price_map) - checkout_ok} checkout sessions failed")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 5. PAYMENT FAILURE — declined card
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("5. Payment failure — declined card scenarios")
|
||||
|
||||
cus_fail = get_or_create_customer("e2e-failure@sandbox.padelnomics.com", "E2E Failure")
|
||||
fail_user = users[2] if len(users) > 2 else users[0]
|
||||
|
||||
# 5a. First create a valid subscription, then simulate payment failure
|
||||
pm_valid = attach_pm(cus_fail.id)
|
||||
try:
|
||||
sub_fail = stripe.Subscription.create(
|
||||
customer=cus_fail.id,
|
||||
items=[{"price": price_map["starter"]["provider_price_id"]}],
|
||||
metadata={"user_id": str(fail_user["id"]), "plan": "starter"},
|
||||
default_payment_method=pm_valid,
|
||||
)
|
||||
cleanup_sub_ids.append(sub_fail.id)
|
||||
ok(f"Created valid sub first: {sub_fail.id} (status={sub_fail.status})")
|
||||
|
||||
# Wait for subscription.created webhook
|
||||
rows = wait_for_row("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub_fail.id,))
|
||||
ok("DB row created") if rows else fail("No DB row after valid sub creation")
|
||||
|
||||
# Now swap to a declined card — next invoice will fail
|
||||
try:
|
||||
pm_decline = stripe.PaymentMethod.create(type="card", card={"token": "tok_chargeDeclined"})
|
||||
stripe.PaymentMethod.attach(pm_decline.id, customer=cus_fail.id)
|
||||
stripe.Customer.modify(cus_fail.id, invoice_settings={"default_payment_method": pm_decline.id})
|
||||
ok("Swapped to declined card for next billing cycle")
|
||||
except stripe.CardError:
|
||||
ok("tok_chargeDeclined rejected at attach (newer API) — card swap skipped")
|
||||
|
||||
cancel_sub(sub_fail.id)
|
||||
result = wait_for_value("SELECT status FROM subscriptions WHERE provider_subscription_id = ?",
|
||||
(sub_fail.id,), "status", "cancelled")
|
||||
ok("Cancelled after failure test") if result else ok("Cleanup done")
|
||||
|
||||
except stripe.CardError as e:
|
||||
ok(f"Card declined at subscription level: {e.user_message}")
|
||||
|
||||
# 5b. Try creating subscription with payment_behavior=default_incomplete
|
||||
try:
|
||||
pm_ok = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
|
||||
stripe.PaymentMethod.attach(pm_ok.id, customer=cus_fail.id)
|
||||
sub_inc = stripe.Subscription.create(
|
||||
customer=cus_fail.id,
|
||||
items=[{"price": price_map["pro"]["provider_price_id"]}],
|
||||
metadata={"user_id": str(fail_user["id"]), "plan": "pro"},
|
||||
default_payment_method=pm_ok.id,
|
||||
payment_behavior="default_incomplete",
|
||||
)
|
||||
cleanup_sub_ids.append(sub_inc.id)
|
||||
ok(f"Incomplete-mode sub: {sub_inc.id} (status={sub_inc.status})")
|
||||
cancel_sub(sub_inc.id)
|
||||
except stripe.StripeError as e:
|
||||
ok(f"Incomplete mode handled: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 6. EDGE CASES
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("6a. Edge case — missing user_id in metadata")
|
||||
|
||||
cus_edge = get_or_create_customer("e2e-edge@sandbox.padelnomics.com", "E2E Edge")
|
||||
pm_edge = attach_pm(cus_edge.id)
|
||||
|
||||
sub = create_sub(cus_edge.id, price_map["starter"]["provider_price_id"],
|
||||
{"plan": "starter"}, # NO user_id
|
||||
pm_edge)
|
||||
ok(f"Created sub without user_id: {sub.id}")
|
||||
|
||||
# Webhook should arrive but handler should not crash (no DB write expected)
|
||||
time.sleep(5)
|
||||
|
||||
# Server should not have crashed — verify it's still up
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ok("Server still alive after missing user_id") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
|
||||
|
||||
section("6b. Edge case — missing supplier_id for supplier plan")
|
||||
|
||||
sub = create_sub(cus_edge.id, price_map["supplier_growth"]["provider_price_id"],
|
||||
{"user_id": str(test_user["id"]), "plan": "supplier_growth"}, # NO supplier_id
|
||||
pm_edge)
|
||||
ok(f"Created supplier sub without supplier_id: {sub.id}")
|
||||
time.sleep(5)
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ok("Server still alive after missing supplier_id") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
|
||||
|
||||
cancel_sub(sub.id)
|
||||
|
||||
|
||||
section("6c. Edge case — duplicate subscription (idempotency)")
|
||||
|
||||
# Create same subscription twice for same user
|
||||
cus_dup = get_or_create_customer("e2e-dup@sandbox.padelnomics.com", "E2E Dup")
|
||||
pm_dup = attach_pm(cus_dup.id)
|
||||
dup_user = users[3] if len(users) > 3 else users[0]
|
||||
|
||||
sub1 = create_sub(cus_dup.id, price_map["starter"]["provider_price_id"],
|
||||
{"user_id": str(dup_user["id"]), "plan": "starter"}, pm_dup)
|
||||
time.sleep(3)
|
||||
|
||||
sub2 = create_sub(cus_dup.id, price_map["pro"]["provider_price_id"],
|
||||
{"user_id": str(dup_user["id"]), "plan": "pro"}, pm_dup)
|
||||
time.sleep(3)
|
||||
|
||||
rows = query_db("SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at", (dup_user["id"],))
|
||||
ok(f"Two subscriptions exist: {len(rows)} rows") if len(rows) >= 2 else fail(f"Expected 2+ rows, got {len(rows)}")
|
||||
|
||||
# get_subscription returns most recent
|
||||
latest = query_db("SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", (dup_user["id"],))
|
||||
if latest and latest[0]["plan"] == "pro":
|
||||
ok("Latest subscription is 'pro' (upgrade scenario)")
|
||||
else:
|
||||
fail(f"Latest plan: {latest[0]['plan'] if latest else '?'}")
|
||||
|
||||
cancel_sub(sub1.id)
|
||||
cancel_sub(sub2.id)
|
||||
|
||||
|
||||
section("6d. Edge case — rapid create + cancel (race condition)")
|
||||
|
||||
cus_race = get_or_create_customer("e2e-race@sandbox.padelnomics.com", "E2E Race")
|
||||
pm_race = attach_pm(cus_race.id)
|
||||
race_user = users[4] if len(users) > 4 else users[0]
|
||||
|
||||
sub = create_sub(cus_race.id, price_map["starter"]["provider_price_id"],
|
||||
{"user_id": str(race_user["id"]), "plan": "starter"}, pm_race)
|
||||
# Cancel immediately — webhooks may arrive out of order
|
||||
stripe.Subscription.cancel(sub.id)
|
||||
ok(f"Created and immediately cancelled: {sub.id}")
|
||||
|
||||
time.sleep(8) # Wait for both webhooks
|
||||
|
||||
rows = query_db("SELECT * FROM subscriptions WHERE provider_subscription_id = ?", (sub.id,))
|
||||
if rows:
|
||||
ok(f"Final DB status: {rows[0]['status']}")
|
||||
else:
|
||||
ok("No DB row (created webhook may have arrived after deleted)")
|
||||
|
||||
result = subprocess.run(
|
||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:5000/"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ok("Server survived race condition") if result.stdout.strip() in ("200", "301", "302") else fail("Server crashed!")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 7. BILLING PORTAL
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("7. Billing Portal session")
|
||||
|
||||
try:
|
||||
portal = stripe.billing_portal.Session.create(
|
||||
customer=cus_starter.id,
|
||||
return_url=f"{tunnel_url}/billing/success",
|
||||
)
|
||||
ok(f"Portal URL: {portal.url[:50]}...")
|
||||
except stripe.StripeError as e:
|
||||
fail(f"Portal failed: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 8. ONE-TIME PAYMENTS (via PaymentIntent — simulates completed checkout)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("8. One-time payments (PaymentIntents — all credit packs + boosts + PDF)")
|
||||
|
||||
cus_buyer = get_or_create_customer("e2e-buyer@sandbox.padelnomics.com", "E2E Buyer")
|
||||
pm_buyer = attach_pm(cus_buyer.id)
|
||||
|
||||
one_time_products = [
|
||||
("credits_25", 9900),
|
||||
("credits_50", 17900),
|
||||
("credits_100", 32900),
|
||||
("credits_250", 74900),
|
||||
("boost_sticky_week", 7900),
|
||||
("boost_sticky_month", 19900),
|
||||
("business_plan", 14900),
|
||||
]
|
||||
|
||||
for key, amount_cents in one_time_products:
|
||||
try:
|
||||
pi = stripe.PaymentIntent.create(
|
||||
amount=amount_cents,
|
||||
currency="eur",
|
||||
customer=cus_buyer.id,
|
||||
payment_method=pm_buyer,
|
||||
confirm=True,
|
||||
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
|
||||
metadata={
|
||||
"user_id": str(test_user["id"]),
|
||||
"supplier_id": str(test_supplier["id"]),
|
||||
"plan": key,
|
||||
},
|
||||
)
|
||||
if pi.status == "succeeded":
|
||||
ok(f"{key}: \u20ac{amount_cents/100:.2f} succeeded ({pi.id[:20]}...)")
|
||||
else:
|
||||
fail(f"{key}: status={pi.status}")
|
||||
except stripe.StripeError as e:
|
||||
fail(f"{key}: {e}")
|
||||
|
||||
# Note: PaymentIntents don't trigger checkout.session.completed webhooks.
|
||||
# The actual credit/boost/PDF creation requires a Checkout Session completion,
|
||||
# which can only happen via browser. These tests verify the payments succeed.
|
||||
print(" (PaymentIntents succeed but don't trigger checkout webhooks —")
|
||||
print(" credit/boost/PDF creation requires browser checkout completion)")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 9. DECLINED CARDS — different failure modes
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("9. Declined card scenarios (PaymentIntent level)")
|
||||
|
||||
decline_tokens = [
|
||||
("tok_chargeDeclined", "generic decline"),
|
||||
("tok_chargeDeclinedInsufficientFunds", "insufficient funds"),
|
||||
("tok_chargeDeclinedExpiredCard", "expired card"),
|
||||
("tok_chargeDeclinedProcessingError", "processing error"),
|
||||
]
|
||||
|
||||
for token, description in decline_tokens:
|
||||
try:
|
||||
pm = stripe.PaymentMethod.create(type="card", card={"token": token})
|
||||
stripe.PaymentMethod.attach(pm.id, customer=cus_buyer.id)
|
||||
pi = stripe.PaymentIntent.create(
|
||||
amount=1900,
|
||||
currency="eur",
|
||||
customer=cus_buyer.id,
|
||||
payment_method=pm.id,
|
||||
confirm=True,
|
||||
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
|
||||
)
|
||||
fail(f"{description}: should have been declined but succeeded")
|
||||
except stripe.CardError as e:
|
||||
ok(f"{description}: correctly declined ({e.code})")
|
||||
except stripe.StripeError as e:
|
||||
ok(f"{description}: rejected ({type(e).__name__})")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("RESULTS")
|
||||
|
||||
total = passed + failed
|
||||
print(f"\n {passed}/{total} passed, {failed} failed\n")
|
||||
|
||||
if errors:
|
||||
print(" Failures:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
print()
|
||||
|
||||
# Final cleanup: cancel any remaining subs
|
||||
for sid in cleanup_sub_ids:
|
||||
try:
|
||||
stripe.Subscription.cancel(sid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
422
scripts/test_stripe_sandbox.py
Normal file
422
scripts/test_stripe_sandbox.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Stripe Sandbox Integration Test — verifies all products work end-to-end.
|
||||
|
||||
Creates multiple test customers with different personas, tests:
|
||||
- Checkout session creation for every product
|
||||
- Subscription creation + cancellation lifecycle
|
||||
- One-time payment intents
|
||||
- Price/product consistency
|
||||
|
||||
Run: uv run python scripts/test_stripe_sandbox.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import stripe
|
||||
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
|
||||
if not STRIPE_SECRET_KEY:
|
||||
print("ERROR: STRIPE_SECRET_KEY / STRIPE_API_PRIVATE_KEY not set in .env")
|
||||
sys.exit(1)
|
||||
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
stripe.max_network_retries = 2
|
||||
|
||||
BASE_URL = os.getenv("BASE_URL", "http://localhost:5000")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Expected product catalog — must match setup_stripe.py
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
EXPECTED_PRODUCTS = {
|
||||
"Supplier Growth": {"price_cents": 19900, "billing": "subscription", "interval": "month"},
|
||||
"Supplier Growth (Yearly)": {"price_cents": 179900, "billing": "subscription", "interval": "year"},
|
||||
"Supplier Pro": {"price_cents": 49900, "billing": "subscription", "interval": "month"},
|
||||
"Supplier Pro (Yearly)": {"price_cents": 449900, "billing": "subscription", "interval": "year"},
|
||||
"Boost: Logo": {"price_cents": 2900, "billing": "subscription", "interval": "month"},
|
||||
"Boost: Highlight": {"price_cents": 3900, "billing": "subscription", "interval": "month"},
|
||||
"Boost: Verified Badge": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
|
||||
"Boost: Custom Card Color": {"price_cents": 5900, "billing": "subscription", "interval": "month"},
|
||||
"Boost: Sticky Top 1 Week": {"price_cents": 7900, "billing": "one_time"},
|
||||
"Boost: Sticky Top 1 Month": {"price_cents": 19900, "billing": "one_time"},
|
||||
"Credit Pack 25": {"price_cents": 9900, "billing": "one_time"},
|
||||
"Credit Pack 50": {"price_cents": 17900, "billing": "one_time"},
|
||||
"Credit Pack 100": {"price_cents": 32900, "billing": "one_time"},
|
||||
"Credit Pack 250": {"price_cents": 74900, "billing": "one_time"},
|
||||
"Padel Business Plan (PDF)": {"price_cents": 14900, "billing": "one_time"},
|
||||
"Planner Starter": {"price_cents": 1900, "billing": "subscription", "interval": "month"},
|
||||
"Planner Pro": {"price_cents": 4900, "billing": "subscription", "interval": "month"},
|
||||
}
|
||||
|
||||
# Test customer personas
|
||||
TEST_CUSTOMERS = [
|
||||
{"email": "planner-starter@sandbox.padelnomics.com", "name": "Anna Planner (Starter)"},
|
||||
{"email": "planner-pro@sandbox.padelnomics.com", "name": "Ben Planner (Pro)"},
|
||||
{"email": "supplier-growth@sandbox.padelnomics.com", "name": "Carlos Supplier (Growth)"},
|
||||
{"email": "supplier-pro@sandbox.padelnomics.com", "name": "Diana Supplier (Pro)"},
|
||||
{"email": "one-time-buyer@sandbox.padelnomics.com", "name": "Eva Buyer (Credits+Boosts)"},
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
|
||||
def ok(msg):
|
||||
global passed
|
||||
passed += 1
|
||||
print(f" ✓ {msg}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
global failed
|
||||
failed += 1
|
||||
errors.append(msg)
|
||||
print(f" ✗ {msg}")
|
||||
|
||||
|
||||
def section(title):
|
||||
print(f"\n{'─' * 60}")
|
||||
print(f" {title}")
|
||||
print(f"{'─' * 60}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 1: Verify all products and prices exist
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 1: Product & Price Verification")
|
||||
|
||||
products = list(stripe.Product.list(limit=100, active=True).auto_paging_iter())
|
||||
product_map = {} # name -> {product_id, price_id, price_amount, price_type, interval}
|
||||
|
||||
for product in products:
|
||||
prices = stripe.Price.list(product=product.id, active=True, limit=1)
|
||||
if not prices.data:
|
||||
continue
|
||||
price = prices.data[0]
|
||||
product_map[product.name] = {
|
||||
"product_id": product.id,
|
||||
"price_id": price.id,
|
||||
"price_amount": price.unit_amount,
|
||||
"price_type": price.type,
|
||||
"interval": price.recurring.interval if price.recurring else None,
|
||||
}
|
||||
|
||||
for name, expected in EXPECTED_PRODUCTS.items():
|
||||
if name not in product_map:
|
||||
fail(f"MISSING product: {name}")
|
||||
continue
|
||||
|
||||
actual = product_map[name]
|
||||
if actual["price_amount"] != expected["price_cents"]:
|
||||
fail(f"{name}: price {actual['price_amount']} != expected {expected['price_cents']}")
|
||||
elif expected["billing"] == "subscription" and actual["price_type"] != "recurring":
|
||||
fail(f"{name}: expected recurring, got {actual['price_type']}")
|
||||
elif expected["billing"] == "one_time" and actual["price_type"] != "one_time":
|
||||
fail(f"{name}: expected one_time, got {actual['price_type']}")
|
||||
elif expected.get("interval") and actual["interval"] != expected["interval"]:
|
||||
fail(f"{name}: interval {actual['interval']} != expected {expected['interval']}")
|
||||
else:
|
||||
ok(f"{name}: €{actual['price_amount']/100:.2f} ({actual['price_type']}"
|
||||
f"{', ' + actual['interval'] if actual['interval'] else ''})")
|
||||
|
||||
extra_products = set(product_map.keys()) - set(EXPECTED_PRODUCTS.keys())
|
||||
if extra_products:
|
||||
print(f"\n ℹ Extra products in Stripe (not in catalog): {extra_products}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 2: Create test customers (idempotent)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 2: Create Test Customers")
|
||||
|
||||
customer_ids = {} # email -> customer_id
|
||||
|
||||
for persona in TEST_CUSTOMERS:
|
||||
existing = stripe.Customer.list(email=persona["email"], limit=1)
|
||||
if existing.data:
|
||||
cus = existing.data[0]
|
||||
ok(f"Reusing: {persona['name']} ({cus.id})")
|
||||
else:
|
||||
cus = stripe.Customer.create(
|
||||
email=persona["email"],
|
||||
name=persona["name"],
|
||||
metadata={"test": "true", "persona": persona["name"]},
|
||||
)
|
||||
ok(f"Created: {persona['name']} ({cus.id})")
|
||||
customer_ids[persona["email"]] = cus.id
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 3: Test Checkout Sessions for every product
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 3: Checkout Session Creation (all products)")
|
||||
|
||||
success_url = f"{BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}"
|
||||
cancel_url = f"{BASE_URL}/billing/pricing"
|
||||
|
||||
# Use the first customer for checkout tests
|
||||
checkout_customer = customer_ids["planner-starter@sandbox.padelnomics.com"]
|
||||
|
||||
for name, info in product_map.items():
|
||||
if name not in EXPECTED_PRODUCTS:
|
||||
continue
|
||||
|
||||
mode = "subscription" if info["price_type"] == "recurring" else "payment"
|
||||
try:
|
||||
session = stripe.checkout.Session.create(
|
||||
mode=mode,
|
||||
customer=checkout_customer,
|
||||
line_items=[{"price": info["price_id"], "quantity": 1}],
|
||||
metadata={"user_id": "999", "plan": name, "test": "true"},
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
ok(f"Checkout ({mode}): {name} -> {session.id[:30]}...")
|
||||
except stripe.StripeError as e:
|
||||
fail(f"Checkout FAILED for {name}: {e.user_message or str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 4: Subscription lifecycle tests (per persona)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 4: Subscription Lifecycle Tests")
|
||||
|
||||
created_subs = []
|
||||
|
||||
# Cache: customer_id -> payment_method_id
|
||||
_customer_pms = {}
|
||||
|
||||
|
||||
def _ensure_payment_method(cus_id):
|
||||
"""Create and attach a test Visa card to a customer (cached)."""
|
||||
if cus_id in _customer_pms:
|
||||
return _customer_pms[cus_id]
|
||||
|
||||
pm = stripe.PaymentMethod.create(type="card", card={"token": "tok_visa"})
|
||||
stripe.PaymentMethod.attach(pm.id, customer=cus_id)
|
||||
stripe.Customer.modify(
|
||||
cus_id,
|
||||
invoice_settings={"default_payment_method": pm.id},
|
||||
)
|
||||
_customer_pms[cus_id] = pm.id
|
||||
return pm.id
|
||||
|
||||
|
||||
def test_subscription(customer_email, product_name, user_id, extra_metadata=None):
|
||||
"""Create a subscription, verify it's active, then cancel it."""
|
||||
cus_id = customer_ids[customer_email]
|
||||
info = product_map.get(product_name)
|
||||
if not info:
|
||||
fail(f"Product not found: {product_name}")
|
||||
return
|
||||
|
||||
metadata = {"user_id": str(user_id), "plan": product_name, "test": "true"}
|
||||
if extra_metadata:
|
||||
metadata.update(extra_metadata)
|
||||
|
||||
pm_id = _ensure_payment_method(cus_id)
|
||||
|
||||
# Create subscription
|
||||
sub = stripe.Subscription.create(
|
||||
customer=cus_id,
|
||||
items=[{"price": info["price_id"]}],
|
||||
metadata=metadata,
|
||||
default_payment_method=pm_id,
|
||||
)
|
||||
created_subs.append(sub.id)
|
||||
|
||||
if sub.status == "active":
|
||||
ok(f"Sub created: {product_name} for {customer_email} -> {sub.id} (active)")
|
||||
else:
|
||||
fail(f"Sub status unexpected: {product_name} -> {sub.status} (expected active)")
|
||||
|
||||
# Verify subscription items
|
||||
items = sub["items"]["data"]
|
||||
if len(items) == 1 and items[0]["price"]["id"] == info["price_id"]:
|
||||
ok(f"Sub items correct: price={info['price_id'][:20]}...")
|
||||
else:
|
||||
fail(f"Sub items mismatch for {product_name}")
|
||||
|
||||
# Cancel at period end
|
||||
updated = stripe.Subscription.modify(sub.id, cancel_at_period_end=True)
|
||||
if updated.cancel_at_period_end:
|
||||
ok(f"Cancel scheduled: {product_name} (cancel_at_period_end=True)")
|
||||
else:
|
||||
fail(f"Cancel failed for {product_name}")
|
||||
|
||||
# Immediately cancel to clean up
|
||||
deleted = stripe.Subscription.cancel(sub.id)
|
||||
if deleted.status == "canceled":
|
||||
ok(f"Cancelled: {product_name} -> {deleted.status}")
|
||||
else:
|
||||
fail(f"Final cancel status: {product_name} -> {deleted.status}")
|
||||
|
||||
|
||||
# Planner Starter
|
||||
test_subscription(
|
||||
"planner-starter@sandbox.padelnomics.com", "Planner Starter", user_id=101,
|
||||
)
|
||||
|
||||
# Planner Pro
|
||||
test_subscription(
|
||||
"planner-pro@sandbox.padelnomics.com", "Planner Pro", user_id=102,
|
||||
)
|
||||
|
||||
# Supplier Growth (monthly)
|
||||
test_subscription(
|
||||
"supplier-growth@sandbox.padelnomics.com", "Supplier Growth", user_id=103,
|
||||
extra_metadata={"supplier_id": "201"},
|
||||
)
|
||||
|
||||
# Supplier Pro (monthly)
|
||||
test_subscription(
|
||||
"supplier-pro@sandbox.padelnomics.com", "Supplier Pro", user_id=104,
|
||||
extra_metadata={"supplier_id": "202"},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 5: One-time payment tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 5: One-Time Payment Tests")
|
||||
|
||||
buyer_id = customer_ids["one-time-buyer@sandbox.padelnomics.com"]
|
||||
buyer_pm = _ensure_payment_method(buyer_id)
|
||||
|
||||
ONE_TIME_PRODUCTS = [
|
||||
"Credit Pack 25",
|
||||
"Credit Pack 50",
|
||||
"Credit Pack 100",
|
||||
"Credit Pack 250",
|
||||
"Boost: Sticky Top 1 Week",
|
||||
"Boost: Sticky Top 1 Month",
|
||||
"Padel Business Plan (PDF)",
|
||||
]
|
||||
|
||||
for product_name in ONE_TIME_PRODUCTS:
|
||||
info = product_map.get(product_name)
|
||||
if not info:
|
||||
fail(f"Product not found: {product_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
pi = stripe.PaymentIntent.create(
|
||||
amount=info["price_amount"],
|
||||
currency="eur",
|
||||
customer=buyer_id,
|
||||
payment_method=buyer_pm,
|
||||
confirm=True,
|
||||
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
|
||||
metadata={
|
||||
"user_id": "105",
|
||||
"supplier_id": "203",
|
||||
"plan": product_name,
|
||||
"test": "true",
|
||||
},
|
||||
)
|
||||
if pi.status == "succeeded":
|
||||
ok(f"Payment: {product_name} -> €{info['price_amount']/100:.2f} ({pi.id[:25]}...)")
|
||||
else:
|
||||
fail(f"Payment status: {product_name} -> {pi.status}")
|
||||
except stripe.StripeError as e:
|
||||
fail(f"Payment FAILED for {product_name}: {e.user_message or str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 6: Boost subscription add-ons
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 6: Boost Add-on Subscriptions")
|
||||
|
||||
BOOST_PRODUCTS = [
|
||||
"Boost: Logo",
|
||||
"Boost: Highlight",
|
||||
"Boost: Verified Badge",
|
||||
"Boost: Custom Card Color",
|
||||
]
|
||||
|
||||
boost_customer = customer_ids["supplier-pro@sandbox.padelnomics.com"]
|
||||
boost_pm = _ensure_payment_method(boost_customer)
|
||||
|
||||
for product_name in BOOST_PRODUCTS:
|
||||
info = product_map.get(product_name)
|
||||
if not info:
|
||||
fail(f"Product not found: {product_name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
sub = stripe.Subscription.create(
|
||||
customer=boost_customer,
|
||||
items=[{"price": info["price_id"]}],
|
||||
metadata={
|
||||
"user_id": "104",
|
||||
"supplier_id": "202",
|
||||
"plan": product_name,
|
||||
"test": "true",
|
||||
},
|
||||
default_payment_method=boost_pm,
|
||||
)
|
||||
created_subs.append(sub.id)
|
||||
|
||||
if sub.status == "active":
|
||||
ok(f"Boost sub: {product_name} -> €{info['price_amount']/100:.2f}/mo ({sub.id[:25]}...)")
|
||||
else:
|
||||
fail(f"Boost sub status: {product_name} -> {sub.status}")
|
||||
|
||||
# Clean up
|
||||
stripe.Subscription.cancel(sub.id)
|
||||
except stripe.StripeError as e:
|
||||
fail(f"Boost sub FAILED for {product_name}: {e.user_message or str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 7: Billing Portal access
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("Phase 7: Billing Portal")
|
||||
|
||||
try:
|
||||
portal = stripe.billing_portal.Session.create(
|
||||
customer=checkout_customer,
|
||||
return_url=f"{BASE_URL}/billing/success",
|
||||
)
|
||||
ok(f"Portal URL generated: {portal.url[:50]}...")
|
||||
except stripe.StripeError as e:
|
||||
fail(f"Portal creation failed: {e.user_message or str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
section("RESULTS")
|
||||
|
||||
total = passed + failed
|
||||
print(f"\n {passed}/{total} passed, {failed} failed\n")
|
||||
|
||||
if errors:
|
||||
print(" Failures:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
print()
|
||||
|
||||
# Customer summary
|
||||
print(" Test customers in sandbox:")
|
||||
for persona in TEST_CUSTOMERS:
|
||||
cid = customer_ids.get(persona["email"], "?")
|
||||
print(f" {persona['name']}: {cid}")
|
||||
|
||||
print()
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -17,14 +17,12 @@ Usage:
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import UTC, datetime
|
||||
@@ -192,9 +190,9 @@ def run_workflow(conn, workflow: dict) -> None:
|
||||
entry_fn = getattr(module, entry_name)
|
||||
entry_fn()
|
||||
logger.info("Workflow %s completed successfully", workflow["name"])
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("Workflow %s failed", workflow["name"])
|
||||
send_alert(f"Workflow '{workflow['name']}' failed")
|
||||
send_alert(f"[extract] {type(exc).__name__}: {str(exc)[:100]}")
|
||||
raise
|
||||
|
||||
|
||||
@@ -233,8 +231,8 @@ def run_due_workflows(conn, workflows: list[dict]) -> bool:
|
||||
# Transform + Export + Deploy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> bool:
|
||||
"""Run a shell command. Returns True on success."""
|
||||
def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> tuple[bool, str]:
|
||||
"""Run a shell command. Returns (success, error_snippet)."""
|
||||
logger.info("Shell: %s", cmd)
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, timeout=timeout_seconds
|
||||
@@ -242,47 +240,88 @@ def run_shell(cmd: str, timeout_seconds: int = SUBPROCESS_TIMEOUT_SECONDS) -> bo
|
||||
if result.returncode != 0:
|
||||
logger.error("Shell failed (rc=%d): %s\nstdout: %s\nstderr: %s",
|
||||
result.returncode, cmd, result.stdout[-500:], result.stderr[-500:])
|
||||
return False
|
||||
return True
|
||||
raw = (result.stderr or result.stdout).strip()
|
||||
snippet = next((ln.strip() for ln in raw.splitlines() if ln.strip()), raw)[:120]
|
||||
return False, snippet
|
||||
return True, ""
|
||||
|
||||
|
||||
def run_transform() -> None:
|
||||
"""Run SQLMesh — it evaluates model staleness internally."""
|
||||
logger.info("Running SQLMesh transform")
|
||||
ok = run_shell(
|
||||
ok, err = run_shell(
|
||||
"uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply",
|
||||
)
|
||||
if not ok:
|
||||
send_alert("SQLMesh transform failed")
|
||||
send_alert(f"[transform] {err}")
|
||||
|
||||
|
||||
def run_export() -> None:
|
||||
"""Export serving tables to analytics.duckdb."""
|
||||
logger.info("Exporting serving tables")
|
||||
ok = run_shell(
|
||||
ok, err = run_shell(
|
||||
f"DUCKDB_PATH={DUCKDB_PATH} SERVING_DUCKDB_PATH={SERVING_DUCKDB_PATH} "
|
||||
f"uv run python src/padelnomics/export_serving.py"
|
||||
)
|
||||
if not ok:
|
||||
send_alert("Serving export failed")
|
||||
send_alert(f"[export] {err}")
|
||||
|
||||
|
||||
_last_seen_head: str | None = None
|
||||
|
||||
|
||||
def web_code_changed() -> bool:
|
||||
"""Check if web app code changed since last deploy (after git pull)."""
|
||||
"""True on the first tick after a commit that changed web app code or secrets.
|
||||
|
||||
Compares the current HEAD to the HEAD from the previous tick. On first call
|
||||
after process start (e.g. after os.execv reloads new code), falls back to
|
||||
HEAD~1 so the just-deployed commit is evaluated exactly once.
|
||||
|
||||
Records HEAD before returning so the same commit never triggers twice.
|
||||
"""
|
||||
global _last_seen_head
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", "web/", "Dockerfile"],
|
||||
["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
current_head = result.stdout.strip()
|
||||
|
||||
if _last_seen_head is None:
|
||||
# Fresh process — use HEAD~1 as base (evaluates the newly deployed tag).
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD~1"], capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
base = base_result.stdout.strip() if base_result.returncode == 0 else current_head
|
||||
else:
|
||||
base = _last_seen_head
|
||||
|
||||
_last_seen_head = current_head # advance now — won't fire again for this HEAD
|
||||
|
||||
if base == current_head:
|
||||
return False
|
||||
|
||||
diff = subprocess.run(
|
||||
["git", "diff", "--name-only", base, current_head, "--",
|
||||
"web/", "Dockerfile", ".env.prod.sops"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
return bool(diff.stdout.strip())
|
||||
|
||||
|
||||
def current_deployed_tag() -> str | None:
|
||||
"""Return the tag currently checked out, or None if not on a tag."""
|
||||
"""Return the highest-version tag pointing at HEAD, or None.
|
||||
|
||||
Uses the same sort order as latest_remote_tag() so that when multiple
|
||||
tags point to the same commit (e.g. a date-based tag and a CI integer
|
||||
tag), we always compare apples-to-apples.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--exact-match", "HEAD"],
|
||||
["git", "tag", "--list", "--sort=-version:refname", "--points-at", "HEAD", "v*"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
tags = result.stdout.strip().splitlines()
|
||||
return tags[0] if tags else None
|
||||
|
||||
|
||||
def latest_remote_tag() -> str | None:
|
||||
@@ -317,7 +356,12 @@ def git_pull_and_sync() -> None:
|
||||
|
||||
logger.info("New tag %s available (current: %s) — deploying", latest, current)
|
||||
run_shell(f"git checkout --detach {latest}")
|
||||
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
|
||||
run_shell("uv sync --all-packages")
|
||||
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
|
||||
# systemd sees it as the same PID and does not restart the unit.
|
||||
logger.info("Deploy complete — re-execing to load new code")
|
||||
os.execv(sys.executable, sys.argv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -365,11 +409,11 @@ def tick() -> None:
|
||||
# Deploy web app if code changed
|
||||
if os.getenv("SUPERVISOR_GIT_PULL") and web_code_changed():
|
||||
logger.info("Web code changed — deploying")
|
||||
ok = run_shell("./deploy.sh")
|
||||
ok, err = run_shell("./deploy.sh")
|
||||
if ok:
|
||||
send_alert("Deploy succeeded")
|
||||
send_alert("[deploy] ok")
|
||||
else:
|
||||
send_alert("Deploy FAILED — check journalctl -u padelnomics-supervisor")
|
||||
send_alert(f"[deploy] failed: {err}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -386,9 +430,9 @@ def supervisor_loop() -> None:
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Supervisor stopped (KeyboardInterrupt)")
|
||||
break
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("Supervisor tick failed — backing off %ds", BACKOFF_SECONDS)
|
||||
send_alert("Supervisor tick failed")
|
||||
send_alert(f"[supervisor] {type(exc).__name__}: {str(exc)[:100]}")
|
||||
time.sleep(BACKOFF_SECONDS)
|
||||
else:
|
||||
time.sleep(TICK_INTERVAL_SECONDS)
|
||||
|
||||
@@ -54,6 +54,7 @@ Grain must match reality — use `QUALIFY ROW_NUMBER()` to enforce it.
|
||||
|
||||
| Dimension | Grain | Used by |
|
||||
|-----------|-------|---------|
|
||||
| `foundation.dim_countries` | `country_code` | `dim_cities`, `dim_locations`, `pseo_city_costs_de`, `planner_defaults` — single source for country names, income, PLI/cost overrides |
|
||||
| `foundation.dim_venues` | `venue_id` | `dim_cities`, `dim_venue_capacity`, `fct_daily_availability` (via capacity join) |
|
||||
| `foundation.dim_cities` | `(country_code, city_slug)` | `serving.city_market_profile` → all pSEO serving models |
|
||||
| `foundation.dim_locations` | `(country_code, geoname_id)` | `serving.location_opportunity_profile` — all GeoNames locations (pop ≥1K), incl. zero-court locations |
|
||||
|
||||
@@ -16,5 +16,107 @@ def padelnomics_glob(evaluator) -> str:
|
||||
return f"'{landing_dir}/padelnomics/**/*.csv.gz'"
|
||||
|
||||
|
||||
# Add one macro per landing zone subdirectory you create.
|
||||
# Pattern: def {source}_glob(evaluator) → f"'{landing_dir}/{source}/**/*.csv.gz'"
|
||||
# ── Country code helpers ─────────────────────────────────────────────────────
|
||||
# Shared lookup used by dim_cities and dim_locations.
|
||||
|
||||
_COUNTRY_NAMES = {
|
||||
"DE": "Germany", "ES": "Spain", "GB": "United Kingdom",
|
||||
"FR": "France", "IT": "Italy", "PT": "Portugal",
|
||||
"AT": "Austria", "CH": "Switzerland", "NL": "Netherlands",
|
||||
"BE": "Belgium", "SE": "Sweden", "NO": "Norway",
|
||||
"DK": "Denmark", "FI": "Finland", "US": "United States",
|
||||
"AR": "Argentina", "MX": "Mexico", "AE": "UAE",
|
||||
"AU": "Australia", "IE": "Ireland",
|
||||
}
|
||||
|
||||
|
||||
def _country_case(col: str) -> str:
|
||||
"""Build a CASE expression mapping ISO 3166-1 alpha-2 → English name."""
|
||||
whens = "\n ".join(
|
||||
f"WHEN '{code}' THEN '{name}'" for code, name in _COUNTRY_NAMES.items()
|
||||
)
|
||||
return f"CASE {col}\n {whens}\n ELSE {col}\n END"
|
||||
|
||||
|
||||
@macro()
|
||||
def country_name(evaluator, code_col) -> str:
|
||||
"""CASE expression: country code → English name.
|
||||
|
||||
Usage in SQL: @country_name(vc.country_code) AS country_name_en
|
||||
"""
|
||||
return _country_case(str(code_col))
|
||||
|
||||
|
||||
@macro()
|
||||
def country_slug(evaluator, code_col) -> str:
|
||||
"""CASE expression: country code → URL-safe slug (lowercased, spaces → dashes).
|
||||
|
||||
Usage in SQL: @country_slug(vc.country_code) AS country_slug
|
||||
"""
|
||||
return f"LOWER(REGEXP_REPLACE({_country_case(str(code_col))}, '[^a-zA-Z0-9]+', '-'))"
|
||||
|
||||
|
||||
@macro()
|
||||
def normalize_eurostat_country(evaluator, code_col) -> str:
|
||||
"""Normalize Eurostat country codes to ISO 3166-1 alpha-2: EL→GR, UK→GB.
|
||||
|
||||
Usage in SQL: @normalize_eurostat_country(geo_code) AS country_code
|
||||
"""
|
||||
col = str(code_col)
|
||||
return f"CASE {col} WHEN 'EL' THEN 'GR' WHEN 'UK' THEN 'GB' ELSE {col} END"
|
||||
|
||||
|
||||
@macro()
|
||||
def normalize_eurostat_nuts(evaluator, code_col) -> str:
|
||||
"""Normalize NUTS code prefix: EL→GR, UK→GB, preserving the suffix.
|
||||
|
||||
Usage in SQL: @normalize_eurostat_nuts(geo_code) AS nuts_code
|
||||
"""
|
||||
col = str(code_col)
|
||||
return (
|
||||
f"CASE"
|
||||
f" WHEN {col} LIKE 'EL%' THEN 'GR' || SUBSTR({col}, 3)"
|
||||
f" WHEN {col} LIKE 'UK%' THEN 'GB' || SUBSTR({col}, 3)"
|
||||
f" ELSE {col}"
|
||||
f" END"
|
||||
)
|
||||
|
||||
|
||||
@macro()
|
||||
def slugify(evaluator, col) -> str:
|
||||
"""URL-safe slug: lowercase → ß→ss → strip accents → non-alnum to dashes → trim.
|
||||
|
||||
Usage in SQL: @slugify(city) AS city_slug
|
||||
"""
|
||||
c = str(col)
|
||||
return (
|
||||
f"TRIM(REGEXP_REPLACE("
|
||||
f"LOWER(STRIP_ACCENTS(REPLACE(LOWER({c}), 'ß', 'ss'))), "
|
||||
f"'[^a-z0-9]+', '-'"
|
||||
f"), '-')"
|
||||
)
|
||||
|
||||
|
||||
@macro()
|
||||
def infer_country_from_coords(evaluator, lat_col, lon_col) -> str:
|
||||
"""Infer ISO country code from lat/lon using bounding boxes for 8 European markets.
|
||||
|
||||
Usage in SQL:
|
||||
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
|
||||
@infer_country_from_coords(lat, lon)) AS country_code
|
||||
"""
|
||||
lat = str(lat_col)
|
||||
lon = str(lon_col)
|
||||
return (
|
||||
f"CASE"
|
||||
f" WHEN {lat} BETWEEN 47.27 AND 55.06 AND {lon} BETWEEN 5.87 AND 15.04 THEN 'DE'"
|
||||
f" WHEN {lat} BETWEEN 35.95 AND 43.79 AND {lon} BETWEEN -9.39 AND 4.33 THEN 'ES'"
|
||||
f" WHEN {lat} BETWEEN 49.90 AND 60.85 AND {lon} BETWEEN -8.62 AND 1.77 THEN 'GB'"
|
||||
f" WHEN {lat} BETWEEN 41.36 AND 51.09 AND {lon} BETWEEN -5.14 AND 9.56 THEN 'FR'"
|
||||
f" WHEN {lat} BETWEEN 45.46 AND 47.80 AND {lon} BETWEEN 5.96 AND 10.49 THEN 'CH'"
|
||||
f" WHEN {lat} BETWEEN 46.37 AND 49.02 AND {lon} BETWEEN 9.53 AND 17.16 THEN 'AT'"
|
||||
f" WHEN {lat} BETWEEN 36.35 AND 47.09 AND {lon} BETWEEN 6.62 AND 18.51 THEN 'IT'"
|
||||
f" WHEN {lat} BETWEEN 37.00 AND 42.15 AND {lon} BETWEEN -9.50 AND -6.19 THEN 'PT'"
|
||||
f" ELSE NULL"
|
||||
f" END"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-- Conformed dimension: used by city_market_profile and all pSEO serving models.
|
||||
-- Integrates four sources:
|
||||
-- dim_venues → city list, venue count, coordinates (Playtomic + OSM)
|
||||
-- stg_income → country-level median income (Eurostat)
|
||||
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
|
||||
-- stg_city_labels → Eurostat city_code → city_name mapping (EU cities)
|
||||
-- stg_population → Eurostat city-level population (EU, joined via city code)
|
||||
-- stg_population_usa → US Census ACS place population
|
||||
@@ -33,8 +33,7 @@ venue_cities AS (
|
||||
SELECT
|
||||
country_code,
|
||||
city AS city_name,
|
||||
-- Lowercase before regex so uppercase letters aren't stripped to '-'
|
||||
LOWER(REGEXP_REPLACE(LOWER(city), '[^a-z0-9]+', '-')) AS city_slug,
|
||||
@slugify(city) AS city_slug,
|
||||
COUNT(*) AS padel_venue_count,
|
||||
AVG(lat) AS centroid_lat,
|
||||
AVG(lon) AS centroid_lon
|
||||
@@ -42,12 +41,6 @@ venue_cities AS (
|
||||
WHERE city IS NOT NULL AND LENGTH(city) > 0
|
||||
GROUP BY country_code, city
|
||||
),
|
||||
-- Latest country income per country
|
||||
country_income AS (
|
||||
SELECT country_code, median_income_pps, ref_year AS income_year
|
||||
FROM staging.stg_income
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
|
||||
),
|
||||
-- Eurostat EU population: join city labels (code→name) with population values.
|
||||
-- QUALIFY keeps only the most recent year per (country, city name).
|
||||
eurostat_pop AS (
|
||||
@@ -109,56 +102,9 @@ SELECT
|
||||
vc.country_code,
|
||||
vc.city_slug,
|
||||
vc.city_name,
|
||||
-- Human-readable country name for pSEO templates and internal linking
|
||||
CASE vc.country_code
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
ELSE vc.country_code
|
||||
END AS country_name_en,
|
||||
-- URL-safe country slug
|
||||
LOWER(REGEXP_REPLACE(
|
||||
CASE vc.country_code
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
ELSE vc.country_code
|
||||
END, '[^a-zA-Z0-9]+', '-'
|
||||
)) AS country_slug,
|
||||
-- Human-readable country name and slug — from dim_countries (single source of truth)
|
||||
c.country_name_en,
|
||||
c.country_slug,
|
||||
vc.centroid_lat AS lat,
|
||||
vc.centroid_lon AS lon,
|
||||
-- Population cascade: Eurostat EU > US Census > ONS UK > GeoNames string > GeoNames spatial > 0.
|
||||
@@ -180,13 +126,13 @@ SELECT
|
||||
0
|
||||
)::INTEGER AS population_year,
|
||||
vc.padel_venue_count,
|
||||
ci.median_income_pps,
|
||||
ci.income_year,
|
||||
c.median_income_pps,
|
||||
c.income_year,
|
||||
-- GeoNames ID: FK to dim_locations / location_opportunity_profile.
|
||||
-- String match preferred; spatial fallback used when name doesn't match (Milano→Milan, etc.)
|
||||
COALESCE(gn.geoname_id, gs.spatial_geoname_id) AS geoname_id
|
||||
FROM venue_cities vc
|
||||
LEFT JOIN country_income ci ON vc.country_code = ci.country_code
|
||||
LEFT JOIN foundation.dim_countries c ON vc.country_code = c.country_code
|
||||
-- Eurostat EU population (via city code→name lookup)
|
||||
LEFT JOIN eurostat_pop ep
|
||||
ON vc.country_code = ep.country_code
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
-- Conformed country dimension — single authoritative source for all country metadata.
|
||||
--
|
||||
-- Consolidates data previously duplicated across dim_cities and dim_locations:
|
||||
-- - country_name_en / country_slug (was: ~50-line CASE blocks in both models)
|
||||
-- - median_income_pps (was: country_income CTE in both models)
|
||||
-- - energy prices, labour costs, PLI indices (new — from Eurostat datasets)
|
||||
-- - cost override columns for the financial calculator
|
||||
--
|
||||
-- Used by: dim_cities, dim_locations, pseo_city_costs_de, planner_defaults.
|
||||
-- Grain: country_code (one row per ISO 3166-1 alpha-2 country code).
|
||||
-- Kind: FULL — small table (~40 rows), full refresh daily.
|
||||
--
|
||||
-- Cost override columns:
|
||||
-- NULL = fall through to calculator.py DEFAULTS (safe: auto-mapping filters None).
|
||||
-- For DE (the baseline country) all overrides are NULL to preserve exact DEFAULTS.
|
||||
-- For countries missing Eurostat data, NULLs propagate naturally.
|
||||
-- camelCase column aliases match DEFAULTS keys for auto-mapping in content/__init__.py.
|
||||
--
|
||||
-- !! DE baseline values sourced from calculator.py DEFAULTS (web/src/padelnomics/planner/calculator.py).
|
||||
-- !! If DEFAULTS change, the hardcoded baseline values below must be updated to match.
|
||||
-- !! Search "DE baseline" in this file to find all affected lines.
|
||||
|
||||
MODEL (
|
||||
name foundation.dim_countries,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain country_code
|
||||
);
|
||||
|
||||
WITH
|
||||
-- Latest income per country
|
||||
latest_income AS (
|
||||
SELECT country_code, median_income_pps, ref_year AS income_year
|
||||
FROM staging.stg_income
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
|
||||
),
|
||||
-- Latest electricity price per country (use most recent semi-annual period)
|
||||
latest_electricity AS (
|
||||
SELECT country_code, electricity_eur_kwh, ref_period
|
||||
FROM staging.stg_electricity_prices
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_period DESC) = 1
|
||||
),
|
||||
-- Latest gas price per country
|
||||
latest_gas AS (
|
||||
SELECT country_code, gas_eur_gj, ref_period
|
||||
FROM staging.stg_gas_prices
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_period DESC) = 1
|
||||
),
|
||||
-- Latest labour cost per country
|
||||
latest_labour AS (
|
||||
SELECT country_code, labour_cost_eur_hour, ref_year
|
||||
FROM staging.stg_labour_costs
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
|
||||
),
|
||||
-- Latest PLI per (country, category)
|
||||
latest_pli AS (
|
||||
SELECT country_code, category, pli, ref_year
|
||||
FROM staging.stg_price_levels
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code, category ORDER BY ref_year DESC) = 1
|
||||
),
|
||||
-- Pivot PLI categories into columns per country
|
||||
pli_pivoted AS (
|
||||
SELECT
|
||||
country_code,
|
||||
MAX(pli) FILTER (WHERE category = 'construction') AS construction,
|
||||
MAX(pli) FILTER (WHERE category = 'housing') AS housing,
|
||||
MAX(pli) FILTER (WHERE category = 'services') AS services,
|
||||
MAX(pli) FILTER (WHERE category = 'misc') AS misc,
|
||||
MAX(pli) FILTER (WHERE category = 'government') AS government
|
||||
FROM latest_pli
|
||||
GROUP BY country_code
|
||||
),
|
||||
-- DE baseline rows for ratio computation
|
||||
-- NULL-safe: if DE is missing from a source, ratios produce NULL (safe fallthrough).
|
||||
de_pli AS (
|
||||
SELECT construction, housing, services, misc, government
|
||||
FROM pli_pivoted WHERE country_code = 'DE'
|
||||
),
|
||||
de_elec AS (
|
||||
SELECT electricity_eur_kwh FROM latest_electricity WHERE country_code = 'DE'
|
||||
),
|
||||
de_gas AS (
|
||||
SELECT gas_eur_gj FROM latest_gas WHERE country_code = 'DE'
|
||||
),
|
||||
-- All distinct country codes from any source
|
||||
all_countries AS (
|
||||
SELECT country_code FROM latest_income
|
||||
UNION
|
||||
SELECT country_code FROM latest_electricity
|
||||
UNION
|
||||
SELECT country_code FROM latest_gas
|
||||
UNION
|
||||
SELECT country_code FROM latest_labour
|
||||
UNION
|
||||
SELECT country_code FROM pli_pivoted
|
||||
-- Ensure known padel markets appear even if Eurostat doesn't cover them yet
|
||||
UNION ALL
|
||||
SELECT unnest(['DE','ES','GB','FR','IT','PT','AT','CH','NL','BE','SE','NO','DK','FI',
|
||||
'US','AR','MX','AE','AU','IE']) AS country_code
|
||||
)
|
||||
SELECT
|
||||
ac.country_code,
|
||||
-- Country name and slug (single definition, replacing duplicated CASE blocks)
|
||||
CASE ac.country_code
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
ELSE ac.country_code
|
||||
END AS country_name_en,
|
||||
LOWER(REGEXP_REPLACE(
|
||||
CASE ac.country_code
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
ELSE ac.country_code
|
||||
END, '[^a-zA-Z0-9]+', '-'
|
||||
)) AS country_slug,
|
||||
-- Income data
|
||||
i.median_income_pps,
|
||||
i.income_year,
|
||||
-- Raw energy and labour data (for reference / future staffed-scenario use)
|
||||
e.electricity_eur_kwh,
|
||||
g.gas_eur_gj,
|
||||
la.labour_cost_eur_hour,
|
||||
-- PLI indices per category (EU27=100)
|
||||
p.construction AS pli_construction,
|
||||
p.housing AS pli_housing,
|
||||
p.services AS pli_services,
|
||||
p.misc AS pli_misc,
|
||||
p.government AS pli_government,
|
||||
-- ── Calculator cost override columns ────────────────────────────────────
|
||||
-- NULL for DE = fall through to calculator.py DEFAULTS (safe: auto-mapping skips None).
|
||||
-- Formulas: country_value = DE_default × (country_price / DE_price)
|
||||
-- or DE_default × (country_PLI / DE_PLI)
|
||||
--
|
||||
-- OPEX overrides — energy (direct price ratio)
|
||||
-- DE baseline: electricity=600, heating=400 (see calculator.py DEFAULTS)
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(600.0 * (e.electricity_eur_kwh / de_e.electricity_eur_kwh), 0)
|
||||
END AS electricity,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(400.0 * (g.gas_eur_gj / de_g.gas_eur_gj), 0)
|
||||
END AS heating,
|
||||
-- OPEX overrides — PLI-scaled (housing category)
|
||||
-- DE baseline: rentSqm=4, water=125, outdoorRent=400
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(4.0 * (p.housing / de_p.housing), 2)
|
||||
END AS rent_sqm,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(125.0 * (p.housing / de_p.housing), 0)
|
||||
END AS water,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(400.0 * (p.housing / de_p.housing), 0)
|
||||
END AS outdoor_rent,
|
||||
-- OPEX overrides — PLI-scaled (misc category)
|
||||
-- DE baseline: insurance=300
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(300.0 * (p.misc / de_p.misc), 0)
|
||||
END AS insurance,
|
||||
-- OPEX overrides — PLI-scaled (services category)
|
||||
-- DE baseline: cleaning=300, maintenance=300, marketing=350
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(300.0 * (p.services / de_p.services), 0)
|
||||
END AS cleaning,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(300.0 * (p.services / de_p.services), 0)
|
||||
END AS maintenance,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(350.0 * (p.services / de_p.services), 0)
|
||||
END AS marketing,
|
||||
-- OPEX overrides — PLI-scaled (government category)
|
||||
-- DE baseline: propertyTax=250, permitsCompliance=12000
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(250.0 * (p.government / de_p.government), 0)
|
||||
END AS property_tax,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(12000.0 * (p.government / de_p.government), 0)
|
||||
END AS permits_compliance,
|
||||
-- CAPEX overrides — PLI-scaled (construction category)
|
||||
-- DE baseline: hallCostSqm=500, foundationSqm=150, hvac=100000, electrical=60000,
|
||||
-- sanitary=80000, parking=50000, fitout=40000, planning=100000,
|
||||
-- fireProtection=80000, floorPrep=12000, hvacUpgrade=20000,
|
||||
-- lightingUpgrade=10000, outdoorFoundation=35, outdoorSiteWork=8000,
|
||||
-- outdoorLighting=4000, outdoorFencing=6000, workingCapital=15000
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(500.0 * (p.construction / de_p.construction), 0)
|
||||
END AS hall_cost_sqm,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(150.0 * (p.construction / de_p.construction), 0)
|
||||
END AS foundation_sqm,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(100000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS hvac,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(60000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS electrical,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(80000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS sanitary,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(50000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS parking,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(40000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS fitout,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(100000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS planning,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(80000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS fire_protection,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(12000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS floor_prep,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(20000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS hvac_upgrade,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(10000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS lighting_upgrade,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(35.0 * (p.construction / de_p.construction), 0)
|
||||
END AS outdoor_foundation,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(8000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS outdoor_site_work,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(4000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS outdoor_lighting,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(6000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS outdoor_fencing,
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(15000.0 * (p.construction / de_p.construction), 0)
|
||||
END AS working_capital,
|
||||
-- CAPEX overrides — PLI-scaled (housing category)
|
||||
-- DE baseline: landPriceSqm=60
|
||||
CASE WHEN ac.country_code = 'DE' THEN NULL
|
||||
ELSE ROUND(60.0 * (p.housing / de_p.housing), 0)
|
||||
END AS land_price_sqm
|
||||
FROM (SELECT DISTINCT country_code FROM all_countries WHERE LENGTH(country_code) = 2) ac
|
||||
LEFT JOIN latest_income i ON ac.country_code = i.country_code
|
||||
LEFT JOIN latest_electricity e ON ac.country_code = e.country_code
|
||||
LEFT JOIN latest_gas g ON ac.country_code = g.country_code
|
||||
LEFT JOIN latest_labour la ON ac.country_code = la.country_code
|
||||
LEFT JOIN pli_pivoted p ON ac.country_code = p.country_code
|
||||
CROSS JOIN de_pli de_p
|
||||
CROSS JOIN de_elec de_e
|
||||
CROSS JOIN de_gas de_g
|
||||
-- Enforce grain
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY ac.country_code ORDER BY ac.country_code) = 1
|
||||
@@ -6,9 +6,9 @@
|
||||
-- covers all locations with population ≥ 1K so zero-court Gemeinden score fully.
|
||||
--
|
||||
-- Enriched with:
|
||||
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
|
||||
-- stg_nuts2_boundaries + stg_regional_income → EU NUTS-2/NUTS-1 income (spatial join)
|
||||
-- stg_income_usa → US state-level income (PPS-normalised)
|
||||
-- stg_income → country-level income (fallback for all countries)
|
||||
-- stg_padel_courts → padel venue count + nearest court distance (km)
|
||||
-- stg_tennis_courts → tennis court count within 25km radius
|
||||
--
|
||||
@@ -16,11 +16,13 @@
|
||||
-- 1. EU NUTS-2 regional income (finest; spatial join via ST_Contains)
|
||||
-- 2. EU NUTS-1 regional income (fallback when NUTS-2 income missing from dataset)
|
||||
-- 3. US state income (ratio-normalised to PPS scale; see us_income CTE)
|
||||
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
||||
-- 4. Country-level income (global fallback from dim_countries / ilc_di03)
|
||||
--
|
||||
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
||||
-- A bounding-box pre-filter (~0.5°, ≈55km) reduces the cross-join before the
|
||||
-- exact sphere distance is computed.
|
||||
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
|
||||
-- (interval join) optimization: O((N+M) log M) vs O(N×M) nested-loop.
|
||||
-- Country pre-filters restrict the left side to ~20K rows for padel/tennis CTEs
|
||||
-- (~8 countries each), down from ~140K global locations.
|
||||
|
||||
MODEL (
|
||||
name foundation.dim_locations,
|
||||
@@ -36,7 +38,7 @@ locations AS (
|
||||
geoname_id,
|
||||
city_name AS location_name,
|
||||
-- URL-safe location slug
|
||||
LOWER(REGEXP_REPLACE(LOWER(city_name), '[^a-z0-9]+', '-')) AS location_slug,
|
||||
@slugify(city_name) AS location_slug,
|
||||
country_code,
|
||||
lat,
|
||||
lon,
|
||||
@@ -47,12 +49,6 @@ locations AS (
|
||||
FROM staging.stg_population_geonames
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
),
|
||||
-- Country income (ilc_di03) — global fallback for all countries
|
||||
country_income AS (
|
||||
SELECT country_code, median_income_pps, ref_year AS income_year
|
||||
FROM staging.stg_income
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
|
||||
),
|
||||
-- ── EU NUTS-2 income via spatial join ──────────────────────────────────────
|
||||
-- Each EU location's (lon, lat) is matched against NUTS-2 boundary polygons.
|
||||
-- The bounding box pre-filter (bbox_lat/lon_min/max) eliminates most candidates
|
||||
@@ -147,6 +143,8 @@ padel_courts AS (
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
),
|
||||
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
||||
-- BETWEEN enables DuckDB IEJoin (O((N+M) log M)) vs ABS() nested-loop (O(N×M)).
|
||||
-- Country pre-filter reduces left side from ~140K to ~20K rows (padel is ~8 countries).
|
||||
nearest_padel AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
@@ -158,9 +156,12 @@ nearest_padel AS (
|
||||
) AS nearest_padel_court_km
|
||||
FROM locations l
|
||||
JOIN padel_courts p
|
||||
-- ~55km bounding box pre-filter to limit cross-join before sphere calc
|
||||
ON ABS(l.lat - p.lat) < 0.5
|
||||
AND ABS(l.lon - p.lon) < 0.5
|
||||
-- ~55km bounding box pre-filter; BETWEEN triggers IEJoin optimization
|
||||
ON l.lat BETWEEN p.lat - 0.5 AND p.lat + 0.5
|
||||
AND l.lon BETWEEN p.lon - 0.5 AND p.lon + 0.5
|
||||
WHERE l.country_code IN (
|
||||
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||
)
|
||||
GROUP BY l.geoname_id
|
||||
),
|
||||
-- Padel venues within 5km of each location (counts as "local padel supply")
|
||||
@@ -170,24 +171,35 @@ padel_local AS (
|
||||
COUNT(*) AS padel_venue_count
|
||||
FROM locations l
|
||||
JOIN padel_courts p
|
||||
ON ABS(l.lat - p.lat) < 0.05 -- ~5km bbox pre-filter
|
||||
AND ABS(l.lon - p.lon) < 0.05
|
||||
WHERE ST_Distance_Sphere(
|
||||
-- ~5km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||
ON l.lat BETWEEN p.lat - 0.05 AND p.lat + 0.05
|
||||
AND l.lon BETWEEN p.lon - 0.05 AND p.lon + 0.05
|
||||
WHERE l.country_code IN (
|
||||
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||
)
|
||||
AND ST_Distance_Sphere(
|
||||
ST_Point(l.lon, l.lat),
|
||||
ST_Point(p.lon, p.lat)
|
||||
) / 1000.0 <= 5.0
|
||||
GROUP BY l.geoname_id
|
||||
),
|
||||
-- Tennis courts within 25km of each location (sports culture proxy)
|
||||
-- Country pre-filter reduces left side from ~140K to ~20K rows (tennis courts are European only).
|
||||
tennis_nearby AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
COUNT(*) AS tennis_courts_within_25km
|
||||
FROM locations l
|
||||
JOIN staging.stg_tennis_courts t
|
||||
ON ABS(l.lat - t.lat) < 0.23 -- ~25km bbox pre-filter
|
||||
AND ABS(l.lon - t.lon) < 0.23
|
||||
WHERE ST_Distance_Sphere(
|
||||
-- ~25km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||
ON l.lat BETWEEN t.lat - 0.23 AND t.lat + 0.23
|
||||
AND l.lon BETWEEN t.lon - 0.23 AND t.lon + 0.23
|
||||
WHERE l.country_code IN (
|
||||
SELECT DISTINCT country_code
|
||||
FROM staging.stg_tennis_courts
|
||||
WHERE country_code IS NOT NULL
|
||||
)
|
||||
AND ST_Distance_Sphere(
|
||||
ST_Point(l.lon, l.lat),
|
||||
ST_Point(t.lon, t.lat)
|
||||
) / 1000.0 <= 25.0
|
||||
@@ -196,56 +208,9 @@ tennis_nearby AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
l.country_code,
|
||||
-- Human-readable country name (consistent with dim_cities)
|
||||
CASE l.country_code
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
ELSE l.country_code
|
||||
END AS country_name_en,
|
||||
-- URL-safe country slug
|
||||
LOWER(REGEXP_REPLACE(
|
||||
CASE l.country_code
|
||||
WHEN 'DE' THEN 'Germany'
|
||||
WHEN 'ES' THEN 'Spain'
|
||||
WHEN 'GB' THEN 'United Kingdom'
|
||||
WHEN 'FR' THEN 'France'
|
||||
WHEN 'IT' THEN 'Italy'
|
||||
WHEN 'PT' THEN 'Portugal'
|
||||
WHEN 'AT' THEN 'Austria'
|
||||
WHEN 'CH' THEN 'Switzerland'
|
||||
WHEN 'NL' THEN 'Netherlands'
|
||||
WHEN 'BE' THEN 'Belgium'
|
||||
WHEN 'SE' THEN 'Sweden'
|
||||
WHEN 'NO' THEN 'Norway'
|
||||
WHEN 'DK' THEN 'Denmark'
|
||||
WHEN 'FI' THEN 'Finland'
|
||||
WHEN 'US' THEN 'United States'
|
||||
WHEN 'AR' THEN 'Argentina'
|
||||
WHEN 'MX' THEN 'Mexico'
|
||||
WHEN 'AE' THEN 'UAE'
|
||||
WHEN 'AU' THEN 'Australia'
|
||||
WHEN 'IE' THEN 'Ireland'
|
||||
ELSE l.country_code
|
||||
END, '[^a-zA-Z0-9]+', '-'
|
||||
)) AS country_slug,
|
||||
-- Human-readable country name and slug — from dim_countries (single source of truth)
|
||||
c.country_name_en,
|
||||
c.country_slug,
|
||||
l.location_name,
|
||||
l.location_slug,
|
||||
l.lat,
|
||||
@@ -258,12 +223,12 @@ SELECT
|
||||
COALESCE(
|
||||
ri.regional_income_pps, -- EU: NUTS-2 (finest) or NUTS-1 (fallback)
|
||||
us.median_income_pps, -- US: state-level PPS-equivalent
|
||||
ci.median_income_pps -- Global: country-level from ilc_di03
|
||||
c.median_income_pps -- Global: country-level from dim_countries / ilc_di03
|
||||
) AS median_income_pps,
|
||||
COALESCE(
|
||||
ri.regional_income_year,
|
||||
us.income_year,
|
||||
ci.income_year
|
||||
c.income_year
|
||||
) AS income_year,
|
||||
COALESCE(pl.padel_venue_count, 0)::INTEGER AS padel_venue_count,
|
||||
-- Venues per 100K residents (NULL if population = 0)
|
||||
@@ -275,8 +240,8 @@ SELECT
|
||||
COALESCE(tn.tennis_courts_within_25km, 0)::INTEGER AS tennis_courts_within_25km,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM locations l
|
||||
LEFT JOIN country_income ci ON l.country_code = ci.country_code
|
||||
LEFT JOIN regional_income ri ON l.geoname_id = ri.geoname_id
|
||||
LEFT JOIN foundation.dim_countries c ON l.country_code = c.country_code
|
||||
LEFT JOIN regional_income ri ON l.geoname_id = ri.geoname_id
|
||||
LEFT JOIN us_income us ON l.country_code = 'US'
|
||||
AND l.admin1_code = us.admin1_code
|
||||
LEFT JOIN nearest_padel np ON l.geoname_id = np.geoname_id
|
||||
|
||||
@@ -99,7 +99,7 @@ SELECT
|
||||
indoor_court_count,
|
||||
outdoor_court_count,
|
||||
-- Conformed city key: enables deterministic joins to dim_cities / venue_pricing_benchmarks
|
||||
LOWER(REGEXP_REPLACE(LOWER(COALESCE(city, '')), '[^a-z0-9]+', '-')) AS city_slug,
|
||||
@slugify(COALESCE(city, '')) AS city_slug,
|
||||
extracted_date
|
||||
FROM ranked
|
||||
QUALIFY ROW_NUMBER() OVER (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Per-venue lat/lon for the city detail dot map.
|
||||
-- Joins dim_venues to dim_cities to attach country_slug and city_slug
|
||||
-- (needed by the /api/markets/<country>/<city>/venues.json endpoint).
|
||||
-- Only rows with valid coordinates are included.
|
||||
|
||||
MODEL (
|
||||
name serving.city_venue_locations,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain venue_id
|
||||
);
|
||||
|
||||
SELECT
|
||||
v.venue_id,
|
||||
v.name,
|
||||
v.lat,
|
||||
v.lon,
|
||||
v.court_count,
|
||||
v.indoor_court_count,
|
||||
v.outdoor_court_count,
|
||||
v.city_slug,
|
||||
c.country_slug
|
||||
FROM foundation.dim_venues v
|
||||
JOIN foundation.dim_cities c
|
||||
ON v.country_code = c.country_code AND v.city_slug = c.city_slug
|
||||
WHERE v.lat IS NOT NULL AND v.lon IS NOT NULL
|
||||
@@ -7,6 +7,10 @@
|
||||
-- 2. Country-level: median across cities in same country
|
||||
-- 3. Hardcoded fallback: market research estimates (only when no Playtomic data)
|
||||
--
|
||||
-- Cost override columns from dim_countries (Eurostat PLI + energy price indices) are
|
||||
-- included so the planner API pre-fills country-adjusted CAPEX/OPEX for all cities.
|
||||
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline preserved).
|
||||
--
|
||||
-- Units are explicit in column names. Monetary values in local currency.
|
||||
|
||||
MODEL (
|
||||
@@ -125,6 +129,37 @@ SELECT
|
||||
ELSE 0.2
|
||||
END AS data_confidence,
|
||||
COALESCE(cb.price_currency, ctb.price_currency, hf.currency, 'EUR') AS price_currency,
|
||||
-- Cost override columns (Eurostat PLI + energy prices via dim_countries).
|
||||
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline).
|
||||
dc.electricity,
|
||||
dc.heating,
|
||||
dc.rent_sqm,
|
||||
dc.insurance,
|
||||
dc.cleaning,
|
||||
dc.maintenance,
|
||||
dc.marketing,
|
||||
dc.water,
|
||||
dc.property_tax,
|
||||
dc.outdoor_rent,
|
||||
dc.hall_cost_sqm,
|
||||
dc.foundation_sqm,
|
||||
dc.land_price_sqm,
|
||||
dc.hvac,
|
||||
dc.electrical,
|
||||
dc.sanitary,
|
||||
dc.parking,
|
||||
dc.fitout,
|
||||
dc.planning,
|
||||
dc.fire_protection,
|
||||
dc.floor_prep,
|
||||
dc.hvac_upgrade,
|
||||
dc.lighting_upgrade,
|
||||
dc.outdoor_foundation,
|
||||
dc.outdoor_site_work,
|
||||
dc.outdoor_lighting,
|
||||
dc.outdoor_fencing,
|
||||
dc.working_capital,
|
||||
dc.permits_compliance,
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM city_profiles cp
|
||||
LEFT JOIN city_benchmarks cb
|
||||
@@ -134,3 +169,5 @@ LEFT JOIN country_benchmarks ctb
|
||||
ON cp.country_code = ctb.country_code
|
||||
LEFT JOIN hardcoded_fallbacks hf
|
||||
ON cp.country_code = hf.country_code
|
||||
LEFT JOIN foundation.dim_countries dc
|
||||
ON cp.country_code = dc.country_code
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
--
|
||||
-- Calculator override columns use camelCase to match the DEFAULTS keys in
|
||||
-- planner/calculator.py, so they are auto-applied as calc pre-fills.
|
||||
--
|
||||
-- Cost override columns come from foundation.dim_countries (Eurostat PLI and energy
|
||||
-- price indices). NULL = fall through to calculator.py DEFAULTS (safe: auto-mapping
|
||||
-- filters None). DE always produces NULL overrides — preserves exact DEFAULTS behaviour.
|
||||
|
||||
MODEL (
|
||||
name serving.pseo_city_costs_de,
|
||||
@@ -22,6 +26,9 @@ SELECT
|
||||
c.country_code,
|
||||
c.country_name_en,
|
||||
c.country_slug,
|
||||
-- City coordinates (for the city venue dot map)
|
||||
c.lat,
|
||||
c.lon,
|
||||
-- Market metrics
|
||||
c.population,
|
||||
c.padel_venue_count,
|
||||
@@ -44,6 +51,39 @@ SELECT
|
||||
FLOOR(p.courts_typical) AS "dblCourts",
|
||||
-- 'country' drives currency formatting in the calculator
|
||||
c.country_code AS "country",
|
||||
-- Cost override columns from dim_countries (Eurostat PLI + energy price indices).
|
||||
-- NULL = fall through to calculator.py DEFAULTS. DE always NULL (baseline preserved).
|
||||
-- OPEX overrides
|
||||
cc.electricity AS "electricity",
|
||||
cc.heating AS "heating",
|
||||
cc.rent_sqm AS "rentSqm",
|
||||
cc.insurance AS "insurance",
|
||||
cc.cleaning AS "cleaning",
|
||||
cc.maintenance AS "maintenance",
|
||||
cc.marketing AS "marketing",
|
||||
cc.water AS "water",
|
||||
cc.property_tax AS "propertyTax",
|
||||
cc.outdoor_rent AS "outdoorRent",
|
||||
-- CAPEX overrides
|
||||
cc.hall_cost_sqm AS "hallCostSqm",
|
||||
cc.foundation_sqm AS "foundationSqm",
|
||||
cc.land_price_sqm AS "landPriceSqm",
|
||||
cc.hvac AS "hvac",
|
||||
cc.electrical AS "electrical",
|
||||
cc.sanitary AS "sanitary",
|
||||
cc.parking AS "parking",
|
||||
cc.fitout AS "fitout",
|
||||
cc.planning AS "planning",
|
||||
cc.fire_protection AS "fireProtection",
|
||||
cc.floor_prep AS "floorPrep",
|
||||
cc.hvac_upgrade AS "hvacUpgrade",
|
||||
cc.lighting_upgrade AS "lightingUpgrade",
|
||||
cc.outdoor_foundation AS "outdoorFoundation",
|
||||
cc.outdoor_site_work AS "outdoorSiteWork",
|
||||
cc.outdoor_lighting AS "outdoorLighting",
|
||||
cc.outdoor_fencing AS "outdoorFencing",
|
||||
cc.working_capital AS "workingCapital",
|
||||
cc.permits_compliance AS "permitsCompliance",
|
||||
CURRENT_DATE AS refreshed_date
|
||||
FROM serving.city_market_profile c
|
||||
LEFT JOIN serving.planner_defaults p
|
||||
@@ -52,6 +92,8 @@ LEFT JOIN serving.planner_defaults p
|
||||
LEFT JOIN serving.location_opportunity_profile lop
|
||||
ON c.country_code = lop.country_code
|
||||
AND c.geoname_id = lop.geoname_id
|
||||
LEFT JOIN foundation.dim_countries cc
|
||||
ON c.country_code = cc.country_code
|
||||
-- Only cities with actual padel presence and at least some rate data
|
||||
WHERE c.padel_venue_count > 0
|
||||
AND (p.rate_peak IS NOT NULL OR c.median_peak_rate IS NOT NULL)
|
||||
|
||||
@@ -20,15 +20,15 @@ SELECT
|
||||
SUM(padel_venue_count) AS total_venues,
|
||||
ROUND(AVG(market_score), 1) AS avg_market_score,
|
||||
MAX(market_score) AS top_city_market_score,
|
||||
-- Top 5 cities by market score for internal linking (DuckDB list slice syntax)
|
||||
LIST(city_slug ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
|
||||
LIST(city_name ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_names,
|
||||
-- Top 5 cities by venue count (prominence), then score for internal linking
|
||||
LIST(city_slug ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
|
||||
LIST(city_name ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_names,
|
||||
-- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG)
|
||||
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
||||
MAX(opportunity_score) AS top_opportunity_score,
|
||||
-- Top 5 cities by opportunity score (may differ from top market score cities)
|
||||
LIST(city_slug ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
|
||||
LIST(city_name ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
|
||||
-- Top 5 opportunity cities by population (prominence), then opportunity score
|
||||
LIST(city_slug ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
|
||||
LIST(city_name ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
|
||||
-- Pricing medians across cities (NULL when no Playtomic coverage in country)
|
||||
ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate,
|
||||
ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Electricity prices for non-household consumers (Eurostat nrg_pc_205).
|
||||
-- EUR/kWh excluding taxes, band MWH500-1999 (medium-sized commercial consumer).
|
||||
-- Semi-annual frequency: ref_period is "YYYY-S1" or "YYYY-S2".
|
||||
--
|
||||
-- Source: data/landing/eurostat/{year}/{month}/nrg_pc_205.json.gz
|
||||
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2024-S1", "value": 0.1523}, ...]}
|
||||
|
||||
MODEL (
|
||||
name staging.stg_electricity_prices,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, ref_period)
|
||||
);
|
||||
|
||||
WITH source AS (
|
||||
SELECT unnest(rows) AS r
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/nrg_pc_205.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
parsed AS (
|
||||
SELECT
|
||||
UPPER(TRIM(r.geo_code)) AS geo_code,
|
||||
TRIM(r.ref_year) AS ref_period,
|
||||
TRY_CAST(r.value AS DOUBLE) AS electricity_eur_kwh
|
||||
FROM source
|
||||
WHERE r.value IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
|
||||
CASE geo_code
|
||||
WHEN 'EL' THEN 'GR'
|
||||
WHEN 'UK' THEN 'GB'
|
||||
ELSE geo_code
|
||||
END AS country_code,
|
||||
ref_period,
|
||||
electricity_eur_kwh
|
||||
FROM parsed
|
||||
WHERE LENGTH(geo_code) = 2
|
||||
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
|
||||
AND electricity_eur_kwh > 0
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Gas prices for non-household consumers (Eurostat nrg_pc_203).
|
||||
-- EUR/GJ excluding taxes, band GJ1000-9999 (medium-sized commercial consumer).
|
||||
-- Semi-annual frequency: ref_period is "YYYY-S1" or "YYYY-S2".
|
||||
--
|
||||
-- Source: data/landing/eurostat/{year}/{month}/nrg_pc_203.json.gz
|
||||
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2024-S1", "value": 14.23}, ...]}
|
||||
|
||||
MODEL (
|
||||
name staging.stg_gas_prices,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, ref_period)
|
||||
);
|
||||
|
||||
WITH source AS (
|
||||
SELECT unnest(rows) AS r
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/nrg_pc_203.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
parsed AS (
|
||||
SELECT
|
||||
UPPER(TRIM(r.geo_code)) AS geo_code,
|
||||
TRIM(r.ref_year) AS ref_period,
|
||||
TRY_CAST(r.value AS DOUBLE) AS gas_eur_gj
|
||||
FROM source
|
||||
WHERE r.value IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
|
||||
CASE geo_code
|
||||
WHEN 'EL' THEN 'GR'
|
||||
WHEN 'UK' THEN 'GB'
|
||||
ELSE geo_code
|
||||
END AS country_code,
|
||||
ref_period,
|
||||
gas_eur_gj
|
||||
FROM parsed
|
||||
WHERE LENGTH(geo_code) = 2
|
||||
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
|
||||
AND gas_eur_gj > 0
|
||||
@@ -30,11 +30,7 @@ parsed AS (
|
||||
)
|
||||
SELECT
|
||||
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
|
||||
CASE geo_code
|
||||
WHEN 'EL' THEN 'GR'
|
||||
WHEN 'UK' THEN 'GB'
|
||||
ELSE geo_code
|
||||
END AS country_code,
|
||||
@normalize_eurostat_country(geo_code) AS country_code,
|
||||
ref_year,
|
||||
median_income_pps,
|
||||
extracted_date
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Labour cost levels EUR/hour (Eurostat lc_lci_lev).
|
||||
-- NACE R2 sector N (administrative and support service activities).
|
||||
-- D1_D2_A_HW structure: wages + non-wage costs, actual hours worked.
|
||||
-- Annual frequency.
|
||||
--
|
||||
-- Stored for future "staffed scenario" calculator variant.
|
||||
-- Not wired into default calculator overrides (staff=0 is a business assumption).
|
||||
--
|
||||
-- Source: data/landing/eurostat/{year}/{month}/lc_lci_lev.json.gz
|
||||
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 28.4}, ...]}
|
||||
|
||||
MODEL (
|
||||
name staging.stg_labour_costs,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, ref_year)
|
||||
);
|
||||
|
||||
WITH source AS (
|
||||
SELECT unnest(rows) AS r
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/lc_lci_lev.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
parsed AS (
|
||||
SELECT
|
||||
UPPER(TRIM(r.geo_code)) AS geo_code,
|
||||
TRY_CAST(r.ref_year AS INTEGER) AS ref_year,
|
||||
TRY_CAST(r.value AS DOUBLE) AS labour_cost_eur_hour
|
||||
FROM source
|
||||
WHERE r.value IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
|
||||
CASE geo_code
|
||||
WHEN 'EL' THEN 'GR'
|
||||
WHEN 'UK' THEN 'GB'
|
||||
ELSE geo_code
|
||||
END AS country_code,
|
||||
ref_year,
|
||||
labour_cost_eur_hour
|
||||
FROM parsed
|
||||
WHERE LENGTH(geo_code) = 2
|
||||
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
|
||||
AND labour_cost_eur_hour > 0
|
||||
@@ -28,11 +28,7 @@ WITH raw AS (
|
||||
SELECT
|
||||
NUTS_ID AS nuts2_code,
|
||||
-- Normalise country prefix to ISO 3166-1 alpha-2: EL→GR, UK→GB
|
||||
CASE CNTR_CODE
|
||||
WHEN 'EL' THEN 'GR'
|
||||
WHEN 'UK' THEN 'GB'
|
||||
ELSE CNTR_CODE
|
||||
END AS country_code,
|
||||
@normalize_eurostat_country(CNTR_CODE) AS country_code,
|
||||
NAME_LATN AS region_name,
|
||||
geom AS geometry,
|
||||
-- Pre-compute bounding box for efficient spatial pre-filter in dim_locations.
|
||||
|
||||
@@ -48,17 +48,8 @@ deduped AS (
|
||||
with_country AS (
|
||||
SELECT
|
||||
osm_id, lat, lon,
|
||||
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE
|
||||
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE'
|
||||
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
|
||||
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
|
||||
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
|
||||
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
|
||||
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
|
||||
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
|
||||
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
|
||||
ELSE NULL
|
||||
END) AS country_code,
|
||||
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
|
||||
@infer_country_from_coords(lat, lon)) AS country_code,
|
||||
NULLIF(TRIM(name), '') AS name,
|
||||
NULLIF(TRIM(city_tag), '') AS city,
|
||||
postcode, operator_name, opening_hours, fee, extracted_date
|
||||
|
||||
@@ -2,22 +2,14 @@
|
||||
-- One row per available 60-minute booking slot per court per venue per day.
|
||||
-- "Available" = the slot was NOT booked at capture time. Missing slots = booked.
|
||||
--
|
||||
-- Reads BOTH morning snapshots and recheck files:
|
||||
-- Morning (new): availability_{date}.jsonl.gz → snapshot_type = 'morning'
|
||||
-- Morning (old): availability_{date}.json.gz → snapshot_type = 'morning'
|
||||
-- Recheck (new): availability_{date}_recheck_{HH}.jsonl.gz → snapshot_type = 'recheck'
|
||||
-- Recheck (old): availability_{date}_recheck_{HH}.json.gz → snapshot_type = 'recheck'
|
||||
-- Reads morning snapshots and recheck files (JSONL format):
|
||||
-- Morning: availability_{date}.jsonl.gz → snapshot_type = 'morning'
|
||||
-- Recheck: availability_{date}_recheck_{HH}.jsonl.gz → snapshot_type = 'recheck'
|
||||
--
|
||||
-- Only 60-min duration slots are kept (canonical hourly rate + occupancy unit).
|
||||
-- Price parsed from strings like "14.56 EUR" or "48 GBP".
|
||||
--
|
||||
-- Supports two morning landing formats (UNION ALL during migration):
|
||||
-- New: availability_{date}.jsonl.gz — one venue per line, columns: tenant_id, slots, date, captured_at_utc
|
||||
-- Old: availability_{date}.json.gz — {"date":..., "venues": [...]} blob (UNNEST required)
|
||||
--
|
||||
-- Requires: at least one availability file in the landing zone.
|
||||
-- A seed file (data/landing/playtomic/1970/01/availability_1970-01-01.json.gz)
|
||||
-- with empty venues[] ensures this model runs before real data arrives.
|
||||
-- Source: data/landing/playtomic/{year}/{month}/availability_*.jsonl.gz
|
||||
|
||||
MODEL (
|
||||
name staging.stg_playtomic_availability,
|
||||
@@ -27,7 +19,6 @@ MODEL (
|
||||
);
|
||||
|
||||
WITH
|
||||
-- New format: one venue per JSONL line — no outer UNNEST needed
|
||||
morning_jsonl AS (
|
||||
SELECT
|
||||
date AS snapshot_date,
|
||||
@@ -50,35 +41,6 @@ morning_jsonl AS (
|
||||
WHERE filename NOT LIKE '%_recheck_%'
|
||||
AND tenant_id IS NOT NULL
|
||||
),
|
||||
-- Old format: {"date":..., "venues": [...]} blob — kept for transition
|
||||
morning_blob AS (
|
||||
SELECT
|
||||
af.date AS snapshot_date,
|
||||
af.captured_at_utc,
|
||||
'morning' AS snapshot_type,
|
||||
NULL::INTEGER AS recheck_hour,
|
||||
venue_json ->> 'tenant_id' AS tenant_id,
|
||||
venue_json -> 'slots' AS slots_json
|
||||
FROM (
|
||||
SELECT date, captured_at_utc, venues
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*.json.gz',
|
||||
format = 'auto',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
captured_at_utc: 'VARCHAR',
|
||||
venues: 'JSON[]'
|
||||
},
|
||||
filename = true,
|
||||
maximum_object_size = 134217728 -- 128 MB; daily files grow with venue count
|
||||
)
|
||||
WHERE filename NOT LIKE '%_recheck_%'
|
||||
AND venues IS NOT NULL
|
||||
AND json_array_length(venues) > 0
|
||||
) af,
|
||||
LATERAL UNNEST(af.venues) AS t(venue_json)
|
||||
),
|
||||
-- Recheck snapshots (new JSONL format — one venue per line)
|
||||
recheck_jsonl AS (
|
||||
SELECT
|
||||
date AS snapshot_date,
|
||||
@@ -101,43 +63,10 @@ recheck_jsonl AS (
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
),
|
||||
-- Recheck snapshots (old blob format, kept for transition)
|
||||
recheck_blob AS (
|
||||
SELECT
|
||||
rf.date AS snapshot_date,
|
||||
rf.captured_at_utc,
|
||||
'recheck' AS snapshot_type,
|
||||
TRY_CAST(
|
||||
regexp_extract(rf.filename, '_recheck_(\d+)', 1) AS INTEGER
|
||||
) AS recheck_hour,
|
||||
venue_json ->> 'tenant_id' AS tenant_id,
|
||||
venue_json -> 'slots' AS slots_json
|
||||
FROM (
|
||||
SELECT date, captured_at_utc, venues, filename
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/availability_*_recheck_*.json.gz',
|
||||
format = 'auto',
|
||||
columns = {
|
||||
date: 'VARCHAR',
|
||||
captured_at_utc: 'VARCHAR',
|
||||
venues: 'JSON[]'
|
||||
},
|
||||
filename = true,
|
||||
maximum_object_size = 134217728 -- 128 MB; matches morning snapshot limit
|
||||
)
|
||||
WHERE venues IS NOT NULL
|
||||
AND json_array_length(venues) > 0
|
||||
) rf,
|
||||
LATERAL UNNEST(rf.venues) AS t(venue_json)
|
||||
),
|
||||
all_venues AS (
|
||||
SELECT * FROM morning_jsonl
|
||||
UNION ALL
|
||||
SELECT * FROM morning_blob
|
||||
UNION ALL
|
||||
SELECT * FROM recheck_jsonl
|
||||
UNION ALL
|
||||
SELECT * FROM recheck_blob
|
||||
),
|
||||
raw_resources AS (
|
||||
SELECT
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
-- DuckDB auto-infers opening_hours as STRUCT, so we access each day by literal
|
||||
-- key (no dynamic access) and UNION ALL to unpivot.
|
||||
--
|
||||
-- Supports two landing formats (UNION ALL during migration):
|
||||
-- New: tenants.jsonl.gz — one tenant per line, opening_hours is a top-level JSON column
|
||||
-- Old: tenants.json.gz — {"tenants": [...]} blob (UNNEST required)
|
||||
--
|
||||
-- Source: data/landing/playtomic/{year}/{month}/tenants.{jsonl,json}.gz
|
||||
-- Source: data/landing/playtomic/{year}/{month}/{day}/tenants.jsonl.gz
|
||||
|
||||
MODEL (
|
||||
name staging.stg_playtomic_opening_hours,
|
||||
@@ -19,40 +15,18 @@ MODEL (
|
||||
);
|
||||
|
||||
WITH
|
||||
-- New format: one tenant per JSONL line
|
||||
jsonl_venues AS (
|
||||
venues AS (
|
||||
SELECT
|
||||
tenant_id,
|
||||
opening_hours AS oh
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/tenants.jsonl.gz',
|
||||
@LANDING_DIR || '/playtomic/*/*/*/tenants.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {tenant_id: 'VARCHAR', opening_hours: 'JSON'}
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
AND opening_hours IS NOT NULL
|
||||
),
|
||||
-- Old format: blob
|
||||
blob_venues AS (
|
||||
SELECT
|
||||
tenant ->> 'tenant_id' AS tenant_id,
|
||||
tenant -> 'opening_hours' AS oh
|
||||
FROM (
|
||||
SELECT UNNEST(tenants) AS tenant
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
|
||||
format = 'auto',
|
||||
maximum_object_size = 134217728
|
||||
)
|
||||
)
|
||||
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
||||
AND (tenant -> 'opening_hours') IS NOT NULL
|
||||
),
|
||||
venues AS (
|
||||
SELECT * FROM jsonl_venues
|
||||
UNION ALL
|
||||
SELECT * FROM blob_venues
|
||||
),
|
||||
-- Unpivot by UNION ALL — 7 literal key accesses
|
||||
unpivoted AS (
|
||||
SELECT tenant_id, 'MONDAY' AS day_of_week, 1 AS day_number,
|
||||
@@ -104,6 +78,4 @@ SELECT
|
||||
FROM unpivoted
|
||||
WHERE opening_time IS NOT NULL
|
||||
AND closing_time IS NOT NULL
|
||||
-- Enforce grain: if both old blob and new JSONL exist for the same month,
|
||||
-- the UNION ALL produces duplicate (tenant_id, day_of_week) pairs — deduplicate.
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY tenant_id, day_of_week ORDER BY tenant_id) = 1
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
-- Reads resources array from the landing zone to extract court type, size,
|
||||
-- surface, and booking config.
|
||||
--
|
||||
-- Supports two landing formats (UNION ALL during migration):
|
||||
-- New: tenants.jsonl.gz — one tenant per line, resources is a top-level JSON column
|
||||
-- Old: tenants.json.gz — {"tenants": [...]} blob (double UNNEST: tenants → resources)
|
||||
--
|
||||
-- Source: data/landing/playtomic/{year}/{month}/tenants.{jsonl,json}.gz
|
||||
-- Source: data/landing/playtomic/{year}/{month}/{day}/tenants.jsonl.gz
|
||||
|
||||
MODEL (
|
||||
name staging.stg_playtomic_resources,
|
||||
@@ -16,41 +12,18 @@ MODEL (
|
||||
);
|
||||
|
||||
WITH
|
||||
-- New format: one tenant per JSONL line — single UNNEST for resources
|
||||
jsonl_unnested AS (
|
||||
unnested AS (
|
||||
SELECT
|
||||
tenant_id,
|
||||
UPPER(address ->> 'country_code') AS country_code,
|
||||
UNNEST(from_json(resources, '["JSON"]')) AS resource_json
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/tenants.jsonl.gz',
|
||||
@LANDING_DIR || '/playtomic/*/*/*/tenants.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {tenant_id: 'VARCHAR', address: 'JSON', resources: 'JSON'}
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
AND resources IS NOT NULL
|
||||
),
|
||||
-- Old format: blob — double UNNEST (tenants → resources)
|
||||
blob_unnested AS (
|
||||
SELECT
|
||||
tenant ->> 'tenant_id' AS tenant_id,
|
||||
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
|
||||
UNNEST(from_json(tenant -> 'resources', '["JSON"]')) AS resource_json
|
||||
FROM (
|
||||
SELECT UNNEST(tenants) AS tenant
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
|
||||
format = 'auto',
|
||||
maximum_object_size = 134217728
|
||||
)
|
||||
)
|
||||
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
||||
AND (tenant -> 'resources') IS NOT NULL
|
||||
),
|
||||
unnested AS (
|
||||
SELECT * FROM jsonl_unnested
|
||||
UNION ALL
|
||||
SELECT * FROM blob_unnested
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
@@ -68,6 +41,4 @@ SELECT
|
||||
FROM unnested
|
||||
WHERE (resource_json ->> 'resource_id') IS NOT NULL
|
||||
AND (resource_json ->> 'sport_id') = 'PADEL'
|
||||
-- Enforce grain: if both old blob and new JSONL exist for the same month,
|
||||
-- the UNION ALL produces duplicate (tenant_id, resource_id) pairs — deduplicate.
|
||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY tenant_id, resource_json ->> 'resource_id' ORDER BY tenant_id) = 1
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
-- including address, opening hours, court resources, VAT rate, and facilities.
|
||||
-- Deduplicates on tenant_id (keeps most recent extraction).
|
||||
--
|
||||
-- Supports two landing formats (UNION ALL during migration):
|
||||
-- New: tenants.jsonl.gz — one tenant JSON object per line (no UNNEST needed)
|
||||
-- Old: tenants.json.gz — {"tenants": [{...}]} blob (UNNEST required)
|
||||
--
|
||||
-- Source: data/landing/playtomic/{year}/{month}/tenants.{jsonl,json}.gz
|
||||
-- Source: data/landing/playtomic/{year}/{month}/{day}/tenants.jsonl.gz
|
||||
|
||||
MODEL (
|
||||
name staging.stg_playtomic_venues,
|
||||
@@ -17,8 +13,7 @@ MODEL (
|
||||
);
|
||||
|
||||
WITH
|
||||
-- New format: one tenant per JSONL line — no UNNEST, access columns directly
|
||||
jsonl_parsed AS (
|
||||
parsed AS (
|
||||
SELECT
|
||||
tenant_id,
|
||||
tenant_name,
|
||||
@@ -45,7 +40,7 @@ jsonl_parsed AS (
|
||||
filename AS source_file,
|
||||
CURRENT_DATE AS extracted_date
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/tenants.jsonl.gz',
|
||||
@LANDING_DIR || '/playtomic/*/*/*/tenants.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
filename = true,
|
||||
columns = {
|
||||
@@ -59,49 +54,6 @@ jsonl_parsed AS (
|
||||
)
|
||||
WHERE tenant_id IS NOT NULL
|
||||
),
|
||||
-- Old format: {"tenants": [...]} blob — keep for transition until old files rotate out
|
||||
blob_parsed AS (
|
||||
SELECT
|
||||
tenant ->> 'tenant_id' AS tenant_id,
|
||||
tenant ->> 'tenant_name' AS tenant_name,
|
||||
tenant ->> 'slug' AS slug,
|
||||
tenant ->> 'tenant_type' AS tenant_type,
|
||||
tenant ->> 'tenant_status' AS tenant_status,
|
||||
tenant ->> 'playtomic_status' AS playtomic_status,
|
||||
tenant ->> 'booking_type' AS booking_type,
|
||||
tenant -> 'address' ->> 'street' AS street,
|
||||
tenant -> 'address' ->> 'city' AS city,
|
||||
tenant -> 'address' ->> 'postal_code' AS postal_code,
|
||||
UPPER(tenant -> 'address' ->> 'country_code') AS country_code,
|
||||
tenant -> 'address' ->> 'timezone' AS timezone,
|
||||
tenant -> 'address' ->> 'administrative_area' AS administrative_area,
|
||||
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lat' AS DOUBLE) AS lat,
|
||||
TRY_CAST(tenant -> 'address' -> 'coordinate' ->> 'lon' AS DOUBLE) AS lon,
|
||||
TRY_CAST(tenant ->> 'vat_rate' AS DOUBLE) AS vat_rate,
|
||||
tenant ->> 'default_currency' AS default_currency,
|
||||
TRY_CAST(tenant -> 'booking_settings' ->> 'booking_ahead_limit' AS INTEGER) AS booking_ahead_limit_minutes,
|
||||
tenant -> 'opening_hours' AS opening_hours_json,
|
||||
tenant -> 'resources' AS resources_json,
|
||||
tenant ->> 'created_at' AS created_at,
|
||||
tenant ->> 'is_playtomic_partner' AS is_playtomic_partner_raw,
|
||||
filename AS source_file,
|
||||
CURRENT_DATE AS extracted_date
|
||||
FROM (
|
||||
SELECT UNNEST(tenants) AS tenant, filename
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/playtomic/*/*/tenants.json.gz',
|
||||
format = 'auto',
|
||||
filename = true,
|
||||
maximum_object_size = 134217728
|
||||
)
|
||||
)
|
||||
WHERE (tenant ->> 'tenant_id') IS NOT NULL
|
||||
),
|
||||
parsed AS (
|
||||
SELECT * FROM jsonl_parsed
|
||||
UNION ALL
|
||||
SELECT * FROM blob_parsed
|
||||
),
|
||||
deduped AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY source_file DESC) AS rn
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
-- Broad coverage (140K+ locations) enables Gemeinde-level market intelligence.
|
||||
-- One row per geoname_id (GeoNames stable numeric identifier).
|
||||
--
|
||||
-- Supports two landing formats (UNION ALL during migration):
|
||||
-- New: cities_global.jsonl.gz — one city per line, columns directly accessible
|
||||
-- Old: cities_global.json.gz — {"rows": [...]} blob (UNNEST required)
|
||||
--
|
||||
-- Source: data/landing/geonames/{year}/{month}/cities_global.{jsonl,json}.gz
|
||||
-- Source: data/landing/geonames/{year}/{month}/cities_global.jsonl.gz
|
||||
|
||||
MODEL (
|
||||
name staging.stg_population_geonames,
|
||||
@@ -16,74 +12,29 @@ MODEL (
|
||||
grain geoname_id
|
||||
);
|
||||
|
||||
WITH
|
||||
-- New format: one city per JSONL line
|
||||
jsonl_rows AS (
|
||||
SELECT
|
||||
TRY_CAST(geoname_id AS INTEGER) AS geoname_id,
|
||||
city_name,
|
||||
country_code,
|
||||
TRY_CAST(lat AS DOUBLE) AS lat,
|
||||
TRY_CAST(lon AS DOUBLE) AS lon,
|
||||
admin1_code,
|
||||
admin2_code,
|
||||
TRY_CAST(population AS BIGINT) AS population,
|
||||
TRY_CAST(ref_year AS INTEGER) AS ref_year,
|
||||
CURRENT_DATE AS extracted_date
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/geonames/*/*/cities_global.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
geoname_id: 'INTEGER', city_name: 'VARCHAR', country_code: 'VARCHAR',
|
||||
lat: 'DOUBLE', lon: 'DOUBLE', admin1_code: 'VARCHAR', admin2_code: 'VARCHAR',
|
||||
population: 'BIGINT', ref_year: 'INTEGER'
|
||||
}
|
||||
)
|
||||
WHERE geoname_id IS NOT NULL
|
||||
),
|
||||
-- Old format: {"rows": [...]} blob — kept for transition
|
||||
blob_rows AS (
|
||||
SELECT
|
||||
TRY_CAST(row ->> 'geoname_id' AS INTEGER) AS geoname_id,
|
||||
row ->> 'city_name' AS city_name,
|
||||
row ->> 'country_code' AS country_code,
|
||||
TRY_CAST(row ->> 'lat' AS DOUBLE) AS lat,
|
||||
TRY_CAST(row ->> 'lon' AS DOUBLE) AS lon,
|
||||
row ->> 'admin1_code' AS admin1_code,
|
||||
row ->> 'admin2_code' AS admin2_code,
|
||||
TRY_CAST(row ->> 'population' AS BIGINT) AS population,
|
||||
TRY_CAST(row ->> 'ref_year' AS INTEGER) AS ref_year,
|
||||
CURRENT_DATE AS extracted_date
|
||||
FROM (
|
||||
SELECT UNNEST(rows) AS row
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/geonames/*/*/cities_global.json.gz',
|
||||
auto_detect = true,
|
||||
maximum_object_size = 40000000
|
||||
)
|
||||
)
|
||||
WHERE (row ->> 'geoname_id') IS NOT NULL
|
||||
),
|
||||
all_rows AS (
|
||||
SELECT * FROM jsonl_rows
|
||||
UNION ALL
|
||||
SELECT * FROM blob_rows
|
||||
)
|
||||
SELECT
|
||||
geoname_id,
|
||||
TRIM(city_name) AS city_name,
|
||||
UPPER(country_code) AS country_code,
|
||||
lat,
|
||||
lon,
|
||||
NULLIF(TRIM(admin1_code), '') AS admin1_code,
|
||||
NULLIF(TRIM(admin2_code), '') AS admin2_code,
|
||||
population,
|
||||
ref_year,
|
||||
extracted_date
|
||||
FROM all_rows
|
||||
WHERE population IS NOT NULL
|
||||
TRY_CAST(geoname_id AS INTEGER) AS geoname_id,
|
||||
TRIM(city_name) AS city_name,
|
||||
UPPER(country_code) AS country_code,
|
||||
TRY_CAST(lat AS DOUBLE) AS lat,
|
||||
TRY_CAST(lon AS DOUBLE) AS lon,
|
||||
NULLIF(TRIM(admin1_code), '') AS admin1_code,
|
||||
NULLIF(TRIM(admin2_code), '') AS admin2_code,
|
||||
TRY_CAST(population AS BIGINT) AS population,
|
||||
TRY_CAST(ref_year AS INTEGER) AS ref_year,
|
||||
CURRENT_DATE AS extracted_date
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/geonames/*/*/cities_global.jsonl.gz',
|
||||
format = 'newline_delimited',
|
||||
columns = {
|
||||
geoname_id: 'INTEGER', city_name: 'VARCHAR', country_code: 'VARCHAR',
|
||||
lat: 'DOUBLE', lon: 'DOUBLE', admin1_code: 'VARCHAR', admin2_code: 'VARCHAR',
|
||||
population: 'BIGINT', ref_year: 'INTEGER'
|
||||
}
|
||||
)
|
||||
WHERE geoname_id IS NOT NULL
|
||||
AND population IS NOT NULL
|
||||
AND population > 0
|
||||
AND geoname_id IS NOT NULL
|
||||
AND city_name IS NOT NULL
|
||||
AND lat IS NOT NULL
|
||||
AND lon IS NOT NULL
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Price level indices relative to EU27=100 (Eurostat prc_ppp_ind).
|
||||
-- Five categories, each from a separate landing file (different ppp_cat filters).
|
||||
-- Annual frequency.
|
||||
--
|
||||
-- Categories and what they scale in the calculator:
|
||||
-- construction — CAPEX: hallCostSqm, foundationSqm, hvac, electrical, sanitary, etc.
|
||||
-- housing — rentSqm, landPriceSqm, water, outdoorRent
|
||||
-- services — cleaning, maintenance, marketing
|
||||
-- misc — insurance
|
||||
-- government — permitsCompliance, propertyTax
|
||||
--
|
||||
-- Sources:
|
||||
-- data/landing/eurostat/*/*/prc_ppp_ind_construction.json.gz (ppp_cat: A050202)
|
||||
-- data/landing/eurostat/*/*/prc_ppp_ind_housing.json.gz (ppp_cat: A0104)
|
||||
-- data/landing/eurostat/*/*/prc_ppp_ind_services.json.gz (ppp_cat: P0201)
|
||||
-- data/landing/eurostat/*/*/prc_ppp_ind_misc.json.gz (ppp_cat: A0112)
|
||||
-- data/landing/eurostat/*/*/prc_ppp_ind_government.json.gz (ppp_cat: P0202)
|
||||
--
|
||||
-- Format: {"rows": [{"geo_code": "DE", "ref_year": "2022", "value": 107.3}, ...]}
|
||||
|
||||
MODEL (
|
||||
name staging.stg_price_levels,
|
||||
kind FULL,
|
||||
cron '@daily',
|
||||
grain (country_code, category, ref_year)
|
||||
);
|
||||
|
||||
WITH construction_raw AS (
|
||||
SELECT unnest(rows) AS r, 'construction' AS category
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_construction.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
housing_raw AS (
|
||||
SELECT unnest(rows) AS r, 'housing' AS category
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_housing.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
services_raw AS (
|
||||
SELECT unnest(rows) AS r, 'services' AS category
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_services.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
misc_raw AS (
|
||||
SELECT unnest(rows) AS r, 'misc' AS category
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_misc.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
government_raw AS (
|
||||
SELECT unnest(rows) AS r, 'government' AS category
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/eurostat/*/*/prc_ppp_ind_government.json.gz',
|
||||
auto_detect = true
|
||||
)
|
||||
),
|
||||
all_raw AS (
|
||||
SELECT r, category FROM construction_raw
|
||||
UNION ALL
|
||||
SELECT r, category FROM housing_raw
|
||||
UNION ALL
|
||||
SELECT r, category FROM services_raw
|
||||
UNION ALL
|
||||
SELECT r, category FROM misc_raw
|
||||
UNION ALL
|
||||
SELECT r, category FROM government_raw
|
||||
),
|
||||
parsed AS (
|
||||
SELECT
|
||||
UPPER(TRIM(r.geo_code)) AS geo_code,
|
||||
TRY_CAST(r.ref_year AS INTEGER) AS ref_year,
|
||||
TRY_CAST(r.value AS DOUBLE) AS pli,
|
||||
category
|
||||
FROM all_raw
|
||||
WHERE r.value IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
-- Normalise to ISO 3166-1 alpha-2: EL→GR, UK→GB
|
||||
CASE geo_code
|
||||
WHEN 'EL' THEN 'GR'
|
||||
WHEN 'UK' THEN 'GB'
|
||||
ELSE geo_code
|
||||
END AS country_code,
|
||||
category,
|
||||
ref_year,
|
||||
pli
|
||||
FROM parsed
|
||||
WHERE LENGTH(geo_code) = 2
|
||||
AND geo_code NOT IN ('EU', 'EA', 'EU27_2020')
|
||||
AND pli > 0
|
||||
@@ -30,11 +30,7 @@ parsed AS (
|
||||
)
|
||||
SELECT
|
||||
-- Normalise to ISO 3166-1 alpha-2 prefix: EL→GR, UK→GB
|
||||
CASE
|
||||
WHEN geo_code LIKE 'EL%' THEN 'GR' || SUBSTR(geo_code, 3)
|
||||
WHEN geo_code LIKE 'UK%' THEN 'GB' || SUBSTR(geo_code, 3)
|
||||
ELSE geo_code
|
||||
END AS nuts_code,
|
||||
@normalize_eurostat_nuts(geo_code) AS nuts_code,
|
||||
-- NUTS level: 3-char = NUTS-1, 4-char = NUTS-2
|
||||
LENGTH(geo_code) - 2 AS nuts_level,
|
||||
ref_year,
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
-- Used as a "racket sport culture" signal in the opportunity score:
|
||||
-- areas with high tennis court density are prime padel adoption markets.
|
||||
--
|
||||
-- Supports two landing formats (UNION ALL during migration):
|
||||
-- New: courts.jsonl.gz — one OSM element per line; nodes have lat/lon directly,
|
||||
-- ways/relations have center.lat/center.lon (Overpass out center)
|
||||
-- Old: courts.json.gz — {"elements": [...]} blob (UNNEST required)
|
||||
--
|
||||
-- Source: data/landing/overpass_tennis/{year}/{month}/courts.{jsonl,json}.gz
|
||||
-- Source: data/landing/overpass_tennis/{year}/{month}/courts.jsonl.gz
|
||||
-- Format: one OSM element per line; nodes have lat/lon directly,
|
||||
-- ways/relations have center.lat/center.lon (Overpass out center)
|
||||
|
||||
MODEL (
|
||||
name staging.stg_tennis_courts,
|
||||
@@ -17,8 +14,7 @@ MODEL (
|
||||
);
|
||||
|
||||
WITH
|
||||
-- New format: one OSM element per JSONL line
|
||||
jsonl_elements AS (
|
||||
parsed AS (
|
||||
SELECT
|
||||
type AS osm_type,
|
||||
TRY_CAST(id AS BIGINT) AS osm_id,
|
||||
@@ -47,33 +43,6 @@ jsonl_elements AS (
|
||||
)
|
||||
WHERE type IS NOT NULL
|
||||
),
|
||||
-- Old format: {"elements": [...]} blob — kept for transition
|
||||
blob_elements AS (
|
||||
SELECT
|
||||
elem ->> 'type' AS osm_type,
|
||||
(elem ->> 'id')::BIGINT AS osm_id,
|
||||
TRY_CAST(elem ->> 'lat' AS DOUBLE) AS lat,
|
||||
TRY_CAST(elem ->> 'lon' AS DOUBLE) AS lon,
|
||||
elem -> 'tags' ->> 'name' AS name,
|
||||
elem -> 'tags' ->> 'addr:country' AS country_code,
|
||||
elem -> 'tags' ->> 'addr:city' AS city_tag,
|
||||
filename AS source_file,
|
||||
CURRENT_DATE AS extracted_date
|
||||
FROM (
|
||||
SELECT UNNEST(elements) AS elem, filename
|
||||
FROM read_json(
|
||||
@LANDING_DIR || '/overpass_tennis/*/*/courts.json.gz',
|
||||
format = 'auto',
|
||||
filename = true
|
||||
)
|
||||
)
|
||||
WHERE (elem ->> 'type') IS NOT NULL
|
||||
),
|
||||
parsed AS (
|
||||
SELECT * FROM jsonl_elements
|
||||
UNION ALL
|
||||
SELECT * FROM blob_elements
|
||||
),
|
||||
deduped AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY osm_id ORDER BY extracted_date DESC) AS rn
|
||||
@@ -85,17 +54,8 @@ deduped AS (
|
||||
with_country AS (
|
||||
SELECT
|
||||
osm_id, lat, lon,
|
||||
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''), CASE
|
||||
WHEN lat BETWEEN 47.27 AND 55.06 AND lon BETWEEN 5.87 AND 15.04 THEN 'DE'
|
||||
WHEN lat BETWEEN 35.95 AND 43.79 AND lon BETWEEN -9.39 AND 4.33 THEN 'ES'
|
||||
WHEN lat BETWEEN 49.90 AND 60.85 AND lon BETWEEN -8.62 AND 1.77 THEN 'GB'
|
||||
WHEN lat BETWEEN 41.36 AND 51.09 AND lon BETWEEN -5.14 AND 9.56 THEN 'FR'
|
||||
WHEN lat BETWEEN 45.46 AND 47.80 AND lon BETWEEN 5.96 AND 10.49 THEN 'CH'
|
||||
WHEN lat BETWEEN 46.37 AND 49.02 AND lon BETWEEN 9.53 AND 17.16 THEN 'AT'
|
||||
WHEN lat BETWEEN 36.35 AND 47.09 AND lon BETWEEN 6.62 AND 18.51 THEN 'IT'
|
||||
WHEN lat BETWEEN 37.00 AND 42.15 AND lon BETWEEN -9.50 AND -6.19 THEN 'PT'
|
||||
ELSE NULL
|
||||
END) AS country_code,
|
||||
COALESCE(NULLIF(TRIM(UPPER(country_code)), ''),
|
||||
@infer_country_from_coords(lat, lon)) AS country_code,
|
||||
NULLIF(TRIM(name), '') AS name,
|
||||
NULLIF(TRIM(city_tag), '') AS city,
|
||||
extracted_date
|
||||
|
||||
15
uv.lock
generated
15
uv.lock
generated
@@ -1392,6 +1392,7 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "quart" },
|
||||
{ name = "resend" },
|
||||
{ name = "stripe" },
|
||||
{ name = "weasyprint" },
|
||||
]
|
||||
|
||||
@@ -1413,6 +1414,7 @@ requires-dist = [
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "quart", specifier = ">=0.19.0" },
|
||||
{ name = "resend", specifier = ">=2.22.0" },
|
||||
{ name = "stripe", specifier = ">=14.4.0" },
|
||||
{ name = "weasyprint", specifier = ">=68.1" },
|
||||
]
|
||||
|
||||
@@ -2519,6 +2521,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "14.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/ec/0f17cff3f7c91b0215266959c5a2a96b0bf9f45ac041c50b99ad8f9b5047/stripe-14.4.0.tar.gz", hash = "sha256:ddaa06f5e38a582bef7e93e06fc304ba8ae3b4c0c2aac43da02c84926f05fa0a", size = 1472370, upload-time = "2026-02-25T17:52:40.905Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/09/fcecad01d76dbe027015dd559ec1b6dccfc319c2540991dde4b1de81ba34/stripe-14.4.0-py3-none-any.whl", hash = "sha256:357151a816cd0bb012d6cb29f108fae50b9f6eece8530d7bc31dfa90c9ceb84c", size = 2115405, upload-time = "2026-02-25T17:52:39.128Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tenacity"
|
||||
version = "9.1.4"
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"httpx>=0.27.0",
|
||||
"google-api-python-client>=2.100.0",
|
||||
"google-auth>=2.23.0",
|
||||
"stripe>=14.4.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -48,7 +48,7 @@ PADDLE_ENVIRONMENT=${PADDLE_ENVIRONMENT:-sandbox}
|
||||
# -- Preparation -------------------------------------------------------------
|
||||
|
||||
info "Resetting database"
|
||||
rm -f "$DATABASE_PATH"
|
||||
rm -f "$DATABASE_PATH" "${DATABASE_PATH}-shm" "${DATABASE_PATH}-wal"
|
||||
ok "Removed $DATABASE_PATH"
|
||||
|
||||
info "Running migrations"
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
"""Create minimal seed files for SQLMesh staging models that require landing data."""
|
||||
"""Create minimal seed files for SQLMesh staging models that require landing data.
|
||||
|
||||
Seeds are empty JSONL gzip files — they satisfy DuckDB's file-not-found check
|
||||
while contributing zero rows to the staging models.
|
||||
"""
|
||||
import gzip
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
seed = {
|
||||
"date": "1970-01-01",
|
||||
"captured_at_utc": "1970-01-01T00:00:00Z",
|
||||
"venue_count": 0,
|
||||
"venues_errored": 0,
|
||||
"venues": [],
|
||||
}
|
||||
morning = Path("data/landing/playtomic/1970/01/availability_1970-01-01.json.gz")
|
||||
recheck = Path("data/landing/playtomic/1970/01/availability_1970-01-01_recheck_00.json.gz")
|
||||
# stg_playtomic_availability requires at least one morning and one recheck file
|
||||
morning = Path("data/landing/playtomic/1970/01/availability_1970-01-01.jsonl.gz")
|
||||
recheck = Path("data/landing/playtomic/1970/01/availability_1970-01-01_recheck_00.jsonl.gz")
|
||||
morning.parent.mkdir(parents=True, exist_ok=True)
|
||||
for p in [morning, recheck]:
|
||||
if not p.exists():
|
||||
with gzip.open(p, "wt") as f:
|
||||
json.dump(seed, f)
|
||||
with gzip.open(p, "wb") as f:
|
||||
pass # empty JSONL — 0 rows, no error
|
||||
print("created", p)
|
||||
else:
|
||||
print("exists ", p)
|
||||
|
||||
@@ -6,7 +6,9 @@ Operational visibility for the data extraction and transformation pipeline:
|
||||
/admin/pipeline/overview → HTMX tab: extraction status, serving freshness, landing stats
|
||||
/admin/pipeline/extractions → HTMX tab: filterable extraction run history
|
||||
/admin/pipeline/extractions/<id>/mark-stale → POST: mark stuck "running" row as failed
|
||||
/admin/pipeline/extract/trigger → POST: enqueue full extraction run
|
||||
/admin/pipeline/extract/trigger → POST: enqueue extraction run (HTMX-aware)
|
||||
/admin/pipeline/transform → HTMX tab: SQLMesh + export status, run history
|
||||
/admin/pipeline/transform/trigger → POST: enqueue transform/export/pipeline step
|
||||
/admin/pipeline/catalog → HTMX tab: data catalog (tables, columns, sample data)
|
||||
/admin/pipeline/catalog/<table> → HTMX partial: table detail (columns + sample)
|
||||
/admin/pipeline/query → HTMX tab: SQL query editor
|
||||
@@ -18,6 +20,7 @@ Data sources:
|
||||
- analytics.duckdb (DuckDB read-only via analytics.execute_user_query)
|
||||
- LANDING_DIR/ (filesystem scan for file sizes + dates)
|
||||
- infra/supervisor/workflows.toml (schedule definitions — tomllib, stdlib)
|
||||
- app.db tasks table (run_transform, run_export, run_pipeline task rows)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -32,7 +35,7 @@ from pathlib import Path
|
||||
from quart import Blueprint, flash, redirect, render_template, request, url_for
|
||||
|
||||
from ..auth.routes import role_required
|
||||
from ..core import csrf_protect
|
||||
from ..core import count_where, csrf_protect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,8 +51,10 @@ bp = Blueprint(
|
||||
_LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
|
||||
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
||||
|
||||
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[5]
|
||||
# In prod the package is installed in a venv so __file__.parents[4] won't
|
||||
# reach the repo checkout. WorkingDirectory in the systemd unit is /opt/padelnomics,
|
||||
# so CWD is reliable; REPO_ROOT env var overrides for non-standard setups.
|
||||
_REPO_ROOT = Path(os.environ.get("REPO_ROOT", ".")).resolve()
|
||||
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
||||
|
||||
# A "running" row older than this is considered stale/crashed.
|
||||
@@ -295,11 +300,8 @@ async def _inject_sidebar_data():
|
||||
"""Load unread inbox count for the admin sidebar badge."""
|
||||
from quart import g
|
||||
|
||||
from ..core import fetch_one
|
||||
|
||||
try:
|
||||
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
|
||||
g.admin_unread_count = row["cnt"] if row else 0
|
||||
g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
|
||||
except Exception:
|
||||
g.admin_unread_count = 0
|
||||
|
||||
@@ -538,6 +540,7 @@ def _load_workflows() -> list[dict]:
|
||||
"schedule": schedule,
|
||||
"schedule_label": schedule_label,
|
||||
"depends_on": config.get("depends_on", []),
|
||||
"description": config.get("description", ""),
|
||||
})
|
||||
return workflows
|
||||
|
||||
@@ -626,10 +629,8 @@ async def pipeline_dashboard():
|
||||
# ── Overview tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@bp.route("/overview")
|
||||
@role_required("admin")
|
||||
async def pipeline_overview():
|
||||
"""HTMX tab: extraction status per source, serving freshness, landing zone."""
|
||||
async def _render_overview_partial():
|
||||
"""Build and render the pipeline overview partial (shared by GET and POST triggers)."""
|
||||
latest_runs, landing_stats, workflows, serving_meta = await asyncio.gather(
|
||||
asyncio.to_thread(_fetch_latest_per_extractor_sync),
|
||||
asyncio.to_thread(_get_landing_zone_stats_sync),
|
||||
@@ -650,6 +651,13 @@ async def pipeline_overview():
|
||||
"stale": _is_stale(run) if run else False,
|
||||
})
|
||||
|
||||
# Treat pending extraction tasks as "running" (queued or active).
|
||||
from ..core import fetch_all as _fetch_all # noqa: PLC0415
|
||||
pending_extraction = await _fetch_all(
|
||||
"SELECT id FROM tasks WHERE task_name = 'run_extraction' AND status = 'pending' LIMIT 1"
|
||||
)
|
||||
any_running = bool(pending_extraction)
|
||||
|
||||
# Compute landing zone totals
|
||||
total_landing_bytes = sum(s["total_bytes"] for s in landing_stats)
|
||||
|
||||
@@ -677,10 +685,18 @@ async def pipeline_overview():
|
||||
total_landing_bytes=total_landing_bytes,
|
||||
serving_tables=serving_tables,
|
||||
last_export=last_export,
|
||||
any_running=any_running,
|
||||
format_bytes=_format_bytes,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/overview")
|
||||
@role_required("admin")
|
||||
async def pipeline_overview():
|
||||
"""HTMX tab: extraction status per source, serving freshness, landing zone."""
|
||||
return await _render_overview_partial()
|
||||
|
||||
|
||||
# ── Extractions tab ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -745,7 +761,11 @@ async def pipeline_mark_stale(run_id: int):
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def pipeline_trigger_extract():
|
||||
"""Enqueue an extraction run — all extractors, or a single named one."""
|
||||
"""Enqueue an extraction run — all extractors, or a single named one.
|
||||
|
||||
HTMX-aware: if the HX-Request header is present, returns the overview partial
|
||||
directly so the UI can update in-place without a redirect.
|
||||
"""
|
||||
from ..worker import enqueue
|
||||
|
||||
form = await request.form
|
||||
@@ -757,11 +777,16 @@ async def pipeline_trigger_extract():
|
||||
await flash(f"Unknown extractor '{extractor}'.", "warning")
|
||||
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||
await enqueue("run_extraction", {"extractor": extractor})
|
||||
await flash(f"Extractor '{extractor}' queued. Check the task queue for progress.", "success")
|
||||
else:
|
||||
await enqueue("run_extraction")
|
||||
await flash("Extraction run queued. Check the task queue for progress.", "success")
|
||||
|
||||
is_htmx = (request.headers.get("HX-Request") == "true"
|
||||
and request.headers.get("HX-Boosted") != "true")
|
||||
if is_htmx:
|
||||
return await _render_overview_partial()
|
||||
|
||||
msg = f"Extractor '{extractor}' queued." if extractor else "Extraction run queued."
|
||||
await flash(f"{msg} Check the task queue for progress.", "success")
|
||||
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||
|
||||
|
||||
@@ -847,6 +872,158 @@ async def pipeline_lineage_schema(model: str):
|
||||
)
|
||||
|
||||
|
||||
# ── Transform tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
_TRANSFORM_TASK_NAMES = ("run_transform", "run_export", "run_pipeline")
|
||||
|
||||
|
||||
async def _fetch_pipeline_tasks() -> dict:
|
||||
"""Fetch the latest task row for each transform task type, plus recent run history.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"latest": {"run_transform": row|None, "run_export": row|None, "run_pipeline": row|None},
|
||||
"history": [row, ...], # last 20 rows across all three task types, newest first
|
||||
}
|
||||
"""
|
||||
from ..core import fetch_all as _fetch_all # noqa: PLC0415
|
||||
|
||||
# Latest row per task type (may be pending, complete, or failed)
|
||||
latest_rows = await _fetch_all(
|
||||
"""
|
||||
SELECT t.*
|
||||
FROM tasks t
|
||||
INNER JOIN (
|
||||
SELECT task_name, MAX(id) AS max_id
|
||||
FROM tasks
|
||||
WHERE task_name IN ('run_transform', 'run_export', 'run_pipeline')
|
||||
GROUP BY task_name
|
||||
) latest ON t.id = latest.max_id
|
||||
"""
|
||||
)
|
||||
latest: dict = {"run_transform": None, "run_export": None, "run_pipeline": None}
|
||||
for row in latest_rows:
|
||||
latest[row["task_name"]] = dict(row)
|
||||
|
||||
history = await _fetch_all(
|
||||
"""
|
||||
SELECT id, task_name, status, created_at, completed_at, error
|
||||
FROM tasks
|
||||
WHERE task_name IN ('run_transform', 'run_export', 'run_pipeline')
|
||||
ORDER BY id DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
return {"latest": latest, "history": [dict(r) for r in history]}
|
||||
|
||||
|
||||
def _format_duration(created_at: str | None, completed_at: str | None) -> str:
|
||||
"""Human-readable duration between created_at and completed_at, or '' if unavailable."""
|
||||
if not created_at or not completed_at:
|
||||
return ""
|
||||
try:
|
||||
fmt = "%Y-%m-%d %H:%M:%S"
|
||||
start = datetime.strptime(created_at, fmt)
|
||||
end = datetime.strptime(completed_at, fmt)
|
||||
delta = int((end - start).total_seconds())
|
||||
if delta < 0:
|
||||
return ""
|
||||
if delta < 60:
|
||||
return f"{delta}s"
|
||||
return f"{delta // 60}m {delta % 60}s"
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
async def _render_transform_partial():
|
||||
"""Build and render the transform tab partial."""
|
||||
task_data = await _fetch_pipeline_tasks()
|
||||
latest = task_data["latest"]
|
||||
history = task_data["history"]
|
||||
|
||||
# Enrich history rows with duration
|
||||
for row in history:
|
||||
row["duration"] = _format_duration(row.get("created_at"), row.get("completed_at"))
|
||||
# Truncate error for display
|
||||
if row.get("error"):
|
||||
row["error_short"] = row["error"][:120]
|
||||
else:
|
||||
row["error_short"] = None
|
||||
|
||||
any_running = any(
|
||||
t is not None and t["status"] == "pending" for t in latest.values()
|
||||
)
|
||||
|
||||
serving_meta = await asyncio.to_thread(_load_serving_meta)
|
||||
|
||||
return await render_template(
|
||||
"admin/partials/pipeline_transform.html",
|
||||
latest=latest,
|
||||
history=history,
|
||||
any_running=any_running,
|
||||
serving_meta=serving_meta,
|
||||
format_duration=_format_duration,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/transform")
|
||||
@role_required("admin")
|
||||
async def pipeline_transform():
|
||||
"""HTMX tab: SQLMesh transform + export status, run history."""
|
||||
return await _render_transform_partial()
|
||||
|
||||
|
||||
@bp.route("/transform/trigger", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def pipeline_trigger_transform():
|
||||
"""Enqueue a transform, export, or full pipeline task.
|
||||
|
||||
form field `step`: 'transform' | 'export' | 'pipeline'
|
||||
Concurrency guard: rejects if the same task type is already pending.
|
||||
HTMX-aware: returns the transform partial for HTMX requests.
|
||||
"""
|
||||
from ..core import fetch_one as _fetch_one # noqa: PLC0415
|
||||
from ..worker import enqueue
|
||||
|
||||
form = await request.form
|
||||
step = (form.get("step") or "").strip()
|
||||
|
||||
step_to_task = {
|
||||
"transform": "run_transform",
|
||||
"export": "run_export",
|
||||
"pipeline": "run_pipeline",
|
||||
}
|
||||
if step not in step_to_task:
|
||||
await flash(f"Unknown step '{step}'.", "warning")
|
||||
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||
|
||||
task_name = step_to_task[step]
|
||||
|
||||
# Concurrency guard: reject if same task type is already pending
|
||||
existing = await _fetch_one(
|
||||
"SELECT id FROM tasks WHERE task_name = ? AND status = 'pending' LIMIT 1",
|
||||
(task_name,),
|
||||
)
|
||||
if existing:
|
||||
is_htmx = (request.headers.get("HX-Request") == "true"
|
||||
and request.headers.get("HX-Boosted") != "true")
|
||||
if is_htmx:
|
||||
return await _render_transform_partial()
|
||||
await flash(f"A '{step}' task is already queued (task #{existing['id']}).", "warning")
|
||||
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||
|
||||
await enqueue(task_name)
|
||||
|
||||
is_htmx = (request.headers.get("HX-Request") == "true"
|
||||
and request.headers.get("HX-Boosted") != "true")
|
||||
if is_htmx:
|
||||
return await _render_transform_partial()
|
||||
|
||||
await flash(f"'{step}' task queued. Check the task queue for progress.", "success")
|
||||
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||
|
||||
|
||||
# ── Catalog tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..content.health import (
|
||||
get_template_freshness,
|
||||
get_template_stats,
|
||||
)
|
||||
from ..core import csrf_protect, fetch_all, fetch_one
|
||||
from ..core import count_where, csrf_protect, fetch_all, fetch_one
|
||||
|
||||
bp = Blueprint(
|
||||
"pseo",
|
||||
@@ -41,8 +41,7 @@ async def _inject_sidebar_data():
|
||||
from quart import g
|
||||
|
||||
try:
|
||||
row = await fetch_one("SELECT COUNT(*) as cnt FROM inbound_emails WHERE is_read = 0")
|
||||
g.admin_unread_count = row["cnt"] if row else 0
|
||||
g.admin_unread_count = await count_where("inbound_emails WHERE is_read = 0")
|
||||
except Exception:
|
||||
g.admin_unread_count = 0
|
||||
|
||||
@@ -80,8 +79,7 @@ async def pseo_dashboard():
|
||||
total_published = sum(r["stats"]["published"] for r in template_rows)
|
||||
stale_count = sum(1 for f in freshness if f["status"] == "stale")
|
||||
|
||||
noindex_row = await fetch_one("SELECT COUNT(*) as cnt FROM articles WHERE noindex = 1")
|
||||
noindex_count = noindex_row["cnt"] if noindex_row else 0
|
||||
noindex_count = await count_where("articles WHERE noindex = 1")
|
||||
|
||||
# Recent generation jobs — enough for the dashboard summary.
|
||||
jobs = await fetch_all(
|
||||
@@ -169,7 +167,6 @@ async def pseo_generate_gaps(slug: str):
|
||||
"template_slug": slug,
|
||||
"start_date": date.today().isoformat(),
|
||||
"articles_per_day": 500,
|
||||
"limit": 500,
|
||||
})
|
||||
await flash(
|
||||
f"Queued generation for {len(gaps)} missing articles in '{config['name']}'.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate_dashboard" %}
|
||||
|
||||
{% block title %}Affiliate Dashboard - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Dashboard</h1>
|
||||
<div class="flex gap-2">
|
||||
{% for d in [7, 30, 90] %}
|
||||
<a href="?days={{ d }}" class="btn-outline btn-sm {% if days_count == d %}active{% endif %}">{{ d }}d</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── Stats strip ── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:1.5rem;">
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Clicks ({{ days_count }}d)</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.total_clicks | int }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Products</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.active_products or 0 }}</div>
|
||||
<div class="text-xs text-slate">{{ stats.draft_products or 0 }} draft</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Articles (clicked)</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ article_count }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Est. Revenue</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">~€{{ est_revenue }}</div>
|
||||
<div class="text-xs text-slate">3% CR × €80 basket</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Daily bar chart ── #}
|
||||
{% if stats.daily_bars %}
|
||||
<div class="card mb-6" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks · Last {{ days_count }} Days</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;overflow-x:auto;">
|
||||
{% for bar in stats.daily_bars %}
|
||||
<div title="{{ bar.day }}: {{ bar.click_count }} clicks"
|
||||
style="flex-shrink:0;width:8px;background:#1D4ED8;border-radius:3px 3px 0 0;min-height:2px;height:{{ bar.pct }}%;transition:opacity .15s;"
|
||||
onmouseover="this.style.opacity='.7'" onmouseout="this.style.opacity='1'">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:.375rem;">
|
||||
<span class="text-xs text-slate">{{ stats.daily_bars[0].day if stats.daily_bars else '' }}</span>
|
||||
<span class="text-xs text-slate">{{ stats.daily_bars[-1].day if stats.daily_bars else '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem;">
|
||||
|
||||
{# ── Top products ── #}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Products</div>
|
||||
{% if stats.top_products %}
|
||||
{% for p in stats.top_products %}
|
||||
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=p.id) }}" style="color:inherit;text-decoration:none;">{{ p.name }}</a>
|
||||
</span>
|
||||
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ p.click_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No clicks yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Top articles ── #}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Articles</div>
|
||||
{% if stats.top_articles %}
|
||||
{% for a in stats.top_articles %}
|
||||
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||
title="{{ a.article_slug }}">{{ a.article_slug }}</span>
|
||||
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ a.click_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No clicks with article source yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Clicks by retailer ── #}
|
||||
{% if stats.by_retailer %}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks by Retailer</div>
|
||||
{% for r in stats.by_retailer %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.75rem;">
|
||||
<span style="width:140px;font-size:.8125rem;color:#0F172A;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
{{ r.retailer or 'Unknown' }}
|
||||
</span>
|
||||
<div style="flex:1;background:#F1F5F9;border-radius:4px;height:24px;overflow:hidden;">
|
||||
<div style="width:{{ r.pct }}%;background:#1D4ED8;height:100%;border-radius:4px;min-width:2px;"></div>
|
||||
</div>
|
||||
<span class="mono" style="font-size:.8125rem;font-weight:600;width:60px;text-align:right;flex-shrink:0;">
|
||||
{{ r.click_count }} <span class="text-slate" style="font-weight:400;">({{ r.share_pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
254
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
254
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
@@ -0,0 +1,254 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit Product{% else %}New Product{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<script>
|
||||
function slugify(text) {
|
||||
return text.toLowerCase()
|
||||
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var nameInput = document.getElementById('f-name');
|
||||
var slugInput = document.getElementById('f-slug');
|
||||
if (nameInput && slugInput && !slugInput.value) {
|
||||
nameInput.addEventListener('input', function() {
|
||||
if (!slugInput.dataset.manual) {
|
||||
slugInput.value = slugify(nameInput.value);
|
||||
}
|
||||
});
|
||||
slugInput.addEventListener('input', function() {
|
||||
slugInput.dataset.manual = '1';
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle program-based vs manual URL fields
|
||||
function toggleProgramFields() {
|
||||
var sel = document.getElementById('f-program');
|
||||
if (!sel) return;
|
||||
var isManual = sel.value === '0' || sel.value === '';
|
||||
document.getElementById('f-product-id-row').style.display = isManual ? 'none' : '';
|
||||
document.getElementById('f-manual-url-row').style.display = isManual ? '' : 'none';
|
||||
}
|
||||
var programSel = document.getElementById('f-program');
|
||||
if (programSel) {
|
||||
programSel.addEventListener('change', toggleProgramFields);
|
||||
toggleProgramFields();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="text-slate text-sm" style="text-decoration:none">← Products</a>
|
||||
<h1 class="text-2xl mt-1">{% if editing %}Edit Product{% else %}New Product{% endif %}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# HTMX preview trigger — outside the grid so it takes no layout space #}
|
||||
<div style="display:none"
|
||||
hx-post="{{ url_for('admin.affiliate_preview') }}"
|
||||
hx-target="#product-preview"
|
||||
hx-trigger="load, input from:#affiliate-form delay:600ms"
|
||||
hx-include="#affiliate-form"
|
||||
hx-push-url="false">
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 380px;gap:2rem;align-items:start" class="affiliate-form-grid">
|
||||
|
||||
{# ── Left: form ── #}
|
||||
<form method="post" id="affiliate-form"
|
||||
action="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
|
||||
|
||||
{# Name #}
|
||||
<div>
|
||||
<label class="form-label" for="f-name">Name *</label>
|
||||
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
|
||||
class="form-input" placeholder="e.g. Bullpadel Vertex 04" required>
|
||||
</div>
|
||||
|
||||
{# Slug #}
|
||||
<div>
|
||||
<label class="form-label" for="f-slug">Slug *</label>
|
||||
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
|
||||
class="form-input" placeholder="e.g. bullpadel-vertex-04-amazon" required
|
||||
pattern="[a-z0-9][a-z0-9\-]*">
|
||||
<p class="form-hint">Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. <code>-amazon</code>, <code>-padelnuestro</code>).</p>
|
||||
</div>
|
||||
|
||||
{# Brand + Category row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-brand">Brand</label>
|
||||
<input id="f-brand" type="text" name="brand" value="{{ data.get('brand','') }}"
|
||||
class="form-input" placeholder="e.g. Bullpadel">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-category">Category</label>
|
||||
<select id="f-category" name="category" class="form-input">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if data.get('category','accessory') == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Program dropdown #}
|
||||
<div>
|
||||
<label class="form-label" for="f-program">Affiliate Program</label>
|
||||
<select id="f-program" name="program_id" class="form-input">
|
||||
<option value="0" {% if not data.get('program_id') %}selected{% endif %}>Manual (custom URL)</option>
|
||||
{% for prog in programs %}
|
||||
<option value="{{ prog.id }}" {% if data.get('program_id') == prog.id %}selected{% endif %}>{{ prog.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="form-hint">Select a program to auto-build the URL, or choose Manual for a custom link.</p>
|
||||
</div>
|
||||
|
||||
{# Product Identifier (shown when program selected) #}
|
||||
<div id="f-product-id-row">
|
||||
<label class="form-label" for="f-product-id">Product ID *</label>
|
||||
<input id="f-product-id" type="text" name="product_identifier"
|
||||
value="{{ data.get('product_identifier','') }}"
|
||||
class="form-input" placeholder="e.g. B0XXXXXXXXX (ASIN for Amazon)">
|
||||
<p class="form-hint">ASIN, product path, or other program-specific identifier. URL is assembled at redirect time.</p>
|
||||
</div>
|
||||
|
||||
{# Manual URL (shown when Manual selected) #}
|
||||
<div id="f-manual-url-row">
|
||||
<label class="form-label" for="f-url">Affiliate URL</label>
|
||||
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
|
||||
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21">
|
||||
<p class="form-hint">Full URL with tracking params already baked in. Used as fallback if no program is set.</p>
|
||||
</div>
|
||||
|
||||
{# Retailer (auto-populated from program; editable for manual products) #}
|
||||
<div>
|
||||
<label class="form-label" for="f-retailer">Retailer <span class="form-hint" style="font-weight:normal">(auto-filled from program)</span></label>
|
||||
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
|
||||
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
||||
list="retailers-list">
|
||||
<datalist id="retailers-list">
|
||||
{% for r in retailers %}
|
||||
<option value="{{ r }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{# Image URL #}
|
||||
<div>
|
||||
<label class="form-label" for="f-image">Image URL</label>
|
||||
<input id="f-image" type="text" name="image_url" value="{{ data.get('image_url','') }}"
|
||||
class="form-input" placeholder="/static/images/affiliate/bullpadel-vertex-04.webp">
|
||||
<p class="form-hint">Local path (recommended) or external URL.</p>
|
||||
</div>
|
||||
|
||||
{# Price + Rating row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-price">Price (EUR)</label>
|
||||
<input id="f-price" type="number" name="price_eur" value="{{ data.get('price_eur','') }}"
|
||||
class="form-input" placeholder="149.99" step="0.01" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-rating">Rating (0–5)</label>
|
||||
<input id="f-rating" type="number" name="rating" value="{{ data.get('rating','') }}"
|
||||
class="form-input" placeholder="4.3" step="0.1" min="0" max="5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
<div>
|
||||
<label class="form-label" for="f-desc">Short Description</label>
|
||||
<textarea id="f-desc" name="description" rows="3"
|
||||
class="form-input" placeholder="One to two sentences describing the product...">{{ data.get('description','') }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Pros #}
|
||||
<div>
|
||||
<label class="form-label" for="f-pros">Pros <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||
<textarea id="f-pros" name="pros" rows="4"
|
||||
class="form-input" placeholder="Carbon frame for maximum power Diamond shape for aggressive players">{{ data.get('pros_text', data.get('pros','')) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Cons #}
|
||||
<div>
|
||||
<label class="form-label" for="f-cons">Cons <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||
<textarea id="f-cons" name="cons" rows="3"
|
||||
class="form-input" placeholder="Only for advanced players">{{ data.get('cons_text', data.get('cons','')) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# CTA Label #}
|
||||
<div>
|
||||
<label class="form-label" for="f-cta">CTA Label</label>
|
||||
<input id="f-cta" type="text" name="cta_label" value="{{ data.get('cta_label','') }}"
|
||||
class="form-input" placeholder='Leave empty for default "Zum Angebot"'>
|
||||
</div>
|
||||
|
||||
{# Status + Language + Sort #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-status">Status</label>
|
||||
<select id="f-status" name="status" class="form-input">
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if data.get('status','draft') == s %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-lang">Language</label>
|
||||
<select id="f-lang" name="language" class="form-input">
|
||||
<option value="de" {% if data.get('language','de') == 'de' %}selected{% endif %}>DE</option>
|
||||
<option value="en" {% if data.get('language','de') == 'en' %}selected{% endif %}>EN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-sort">Sort Order</label>
|
||||
<input id="f-sort" type="number" name="sort_order" value="{{ data.get('sort_order', 0) }}"
|
||||
class="form-input" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn" formaction="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
|
||||
{% if editing %}Save Changes{% else %}Create Product{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
|
||||
</div>
|
||||
{% if editing %}
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline"
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Right: live preview ── #}
|
||||
<div style="position:sticky;top:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-2" style="text-transform:uppercase;letter-spacing:.06em;">Preview</div>
|
||||
<div id="product-preview" style="border:1px solid #E2E8F0;border-radius:12px;padding:1rem;background:#F8FAFC;min-height:180px;">
|
||||
<p style="color:#94A3B8;font-size:.875rem;text-align:center;margin-top:2rem;">Loading preview…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 900px) {
|
||||
.affiliate-form-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate" %}
|
||||
|
||||
{% block title %}Affiliate Products - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Products</h1>
|
||||
<a href="{{ url_for('admin.affiliate_new') }}" class="btn btn-sm">+ New Product</a>
|
||||
</header>
|
||||
|
||||
{# Filters #}
|
||||
<div class="card mb-6" style="padding:1rem 1.25rem">
|
||||
<form class="flex flex-wrap gap-3 items-end"
|
||||
hx-get="{{ url_for('admin.affiliate_results') }}"
|
||||
hx-target="#aff-results"
|
||||
hx-trigger="change, input delay:300ms"
|
||||
hx-indicator="#aff-loading">
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="Name or brand..."
|
||||
class="form-input" style="min-width:200px">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Category</label>
|
||||
<select name="category" class="form-input" style="min-width:120px">
|
||||
<option value="">All</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if cat == category %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Retailer</label>
|
||||
<select name="retailer" class="form-input" style="min-width:140px">
|
||||
<option value="">All</option>
|
||||
{% for r in retailers %}
|
||||
<option value="{{ r }}" {% if r == retailer_filter %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||
<select name="status" class="form-input" style="min-width:110px">
|
||||
<option value="">All</option>
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if s == status_filter %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<svg id="aff-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<div id="aff-results">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Brand</th>
|
||||
<th>Retailer</th>
|
||||
<th>Category</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Clicks</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% include "admin/partials/affiliate_results.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate_programs" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit Program{% else %}New Program{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<script>
|
||||
function slugify(text) {
|
||||
return text.toLowerCase()
|
||||
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var nameInput = document.getElementById('f-name');
|
||||
var slugInput = document.getElementById('f-slug');
|
||||
if (nameInput && slugInput && !slugInput.value) {
|
||||
nameInput.addEventListener('input', function() {
|
||||
if (!slugInput.dataset.manual) {
|
||||
slugInput.value = slugify(nameInput.value);
|
||||
}
|
||||
});
|
||||
slugInput.addEventListener('input', function() {
|
||||
slugInput.dataset.manual = '1';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.affiliate_programs') }}" class="text-slate text-sm" style="text-decoration:none">← Programs</a>
|
||||
<h1 class="text-2xl mt-1">{% if editing %}Edit Program{% else %}New Program{% endif %}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="max-width:600px">
|
||||
<form method="post" id="program-form"
|
||||
action="{% if editing %}{{ url_for('admin.affiliate_program_edit', program_id=program_id) }}{% else %}{{ url_for('admin.affiliate_program_new') }}{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
|
||||
|
||||
{# Name #}
|
||||
<div>
|
||||
<label class="form-label" for="f-name">Name *</label>
|
||||
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
|
||||
class="form-input" placeholder="e.g. Amazon, Padel Nuestro" required>
|
||||
</div>
|
||||
|
||||
{# Slug #}
|
||||
<div>
|
||||
<label class="form-label" for="f-slug">Slug *</label>
|
||||
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
|
||||
class="form-input" placeholder="e.g. amazon, padel-nuestro" required
|
||||
pattern="[a-z0-9][a-z0-9\-]*">
|
||||
<p class="form-hint">Lowercase letters, numbers, hyphens only.</p>
|
||||
</div>
|
||||
|
||||
{# URL Template #}
|
||||
<div>
|
||||
<label class="form-label" for="f-template">URL Template *</label>
|
||||
<input id="f-template" type="text" name="url_template" value="{{ data.get('url_template','') }}"
|
||||
class="form-input" placeholder="https://www.amazon.de/dp/{product_id}?tag={tag}" required>
|
||||
<p class="form-hint">
|
||||
Use <code>{product_id}</code> for the ASIN/product path and <code>{tag}</code> for the tracking tag.<br>
|
||||
Example: <code>https://www.amazon.de/dp/{product_id}?tag={tag}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Tracking Tag + Commission row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-tag">Tracking Tag</label>
|
||||
<input id="f-tag" type="text" name="tracking_tag" value="{{ data.get('tracking_tag','') }}"
|
||||
class="form-input" placeholder="e.g. padelnomics-21">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-commission">Commission %</label>
|
||||
<input id="f-commission" type="number" name="commission_pct" value="{{ data.get('commission_pct', 0) }}"
|
||||
class="form-input" placeholder="3" step="0.1" min="0" max="100">
|
||||
<p class="form-hint">Used for revenue estimates (e.g. 3 = 3%).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Homepage URL #}
|
||||
<div>
|
||||
<label class="form-label" for="f-homepage">Homepage URL</label>
|
||||
<input id="f-homepage" type="url" name="homepage_url" value="{{ data.get('homepage_url','') }}"
|
||||
class="form-input" placeholder="https://www.amazon.de">
|
||||
<p class="form-hint">Shown as a link in the programs list.</p>
|
||||
</div>
|
||||
|
||||
{# Status #}
|
||||
<div>
|
||||
<label class="form-label" for="f-status">Status</label>
|
||||
<select id="f-status" name="status" class="form-input">
|
||||
{% for s in program_statuses %}
|
||||
<option value="{{ s }}" {% if data.get('status','active') == s %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="form-hint">Inactive programs are hidden from the product form dropdown.</p>
|
||||
</div>
|
||||
|
||||
{# Notes #}
|
||||
<div>
|
||||
<label class="form-label" for="f-notes">Notes <span class="form-hint" style="font-weight:normal">(internal)</span></label>
|
||||
<textarea id="f-notes" name="notes" rows="3"
|
||||
class="form-input" placeholder="Login URL, account ID, affiliate dashboard link...">{{ data.get('notes','') }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn">
|
||||
{% if editing %}Save Changes{% else %}Create Program{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.affiliate_programs') }}" class="btn-outline">Cancel</a>
|
||||
</div>
|
||||
{% if editing %}
|
||||
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=program_id) }}" style="margin:0" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline"
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate_programs" %}
|
||||
|
||||
{% block title %}Affiliate Programs - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Programs</h1>
|
||||
<a href="{{ url_for('admin.affiliate_program_new') }}" class="btn btn-sm">+ New Program</a>
|
||||
</header>
|
||||
|
||||
<div id="prog-results">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Tracking Tag</th>
|
||||
<th class="text-right">Commission</th>
|
||||
<th class="text-right">Products</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% include "admin/partials/affiliate_program_results.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,89 +1,413 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "articles" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article — Admin — {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}{{ super() }}
|
||||
<style>
|
||||
/* Override admin-main so the split editor fills the column */
|
||||
.admin-main {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Editor shell ──────────────────────────────────────────── */
|
||||
.ae-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Toolbar ────────────────────────────────────────────────── */
|
||||
.ae-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-toolbar__back {
|
||||
font-size: 0.8125rem;
|
||||
color: #64748B;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.ae-toolbar__back:hover { color: #0F172A; }
|
||||
.ae-toolbar__sep {
|
||||
width: 1px; height: 1.25rem;
|
||||
background: #E2E8F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-toolbar__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #0F172A;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ae-toolbar__status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-toolbar__status--draft {
|
||||
background: #F1F5F9;
|
||||
color: #64748B;
|
||||
}
|
||||
.ae-toolbar__status--published {
|
||||
background: #DCFCE7;
|
||||
color: #16A34A;
|
||||
}
|
||||
|
||||
/* ── Metadata strip ─────────────────────────────────────────── */
|
||||
#ae-form {
|
||||
display: contents; /* form participates in flex layout as transparent wrapper */
|
||||
}
|
||||
.ae-meta {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #F8FAFC;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-meta__row {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
.ae-meta__row + .ae-meta__row { margin-top: 0.5rem; }
|
||||
|
||||
.ae-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ae-field--flex1 { flex: 1; min-width: 120px; }
|
||||
.ae-field--flex2 { flex: 2; min-width: 180px; }
|
||||
.ae-field--flex3 { flex: 3; min-width: 220px; }
|
||||
.ae-field--fixed80 { flex: 0 0 80px; }
|
||||
.ae-field--fixed120 { flex: 0 0 120px; }
|
||||
.ae-field--fixed160 { flex: 0 0 160px; }
|
||||
|
||||
.ae-field label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94A3B8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ae-field input,
|
||||
.ae-field select {
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-sans);
|
||||
color: #0F172A;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
.ae-field input:focus,
|
||||
.ae-field select:focus {
|
||||
border-color: #1D4ED8;
|
||||
box-shadow: 0 0 0 2px rgba(29,78,216,0.1);
|
||||
}
|
||||
.ae-field input[readonly] {
|
||||
background: #F1F5F9;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
/* ── Split pane ─────────────────────────────────────────────── */
|
||||
.ae-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ae-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ae-pane--editor { border-right: 1px solid #E2E8F0; }
|
||||
|
||||
.ae-pane__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.875rem;
|
||||
background: #F8FAFC;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-pane--preview .ae-pane__header {
|
||||
background: #F8FAFC;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.ae-pane__label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: #94A3B8;
|
||||
}
|
||||
.ae-pane__hint {
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--font-mono);
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
/* The markdown textarea */
|
||||
.ae-editor {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 1.5rem 2rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.8;
|
||||
background: #FEFDFB;
|
||||
color: #1E293B;
|
||||
caret-color: #1D4ED8;
|
||||
tab-size: 2;
|
||||
}
|
||||
.ae-editor::placeholder { color: #CBD5E1; }
|
||||
.ae-editor:focus { outline: none; }
|
||||
|
||||
/* Preview pane — iframe fills the content area */
|
||||
#ae-preview-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
font-size: 0.875rem;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
margin: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* Collapsible metadata */
|
||||
.ae-meta--collapsed { display: none; }
|
||||
|
||||
.ae-toolbar__toggle {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748B;
|
||||
background: none;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
|
||||
|
||||
/* Word count footer */
|
||||
.ae-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem 0.875rem;
|
||||
background: #F8FAFC;
|
||||
border-top: 1px solid #E2E8F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ae-wordcount {
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--font-mono);
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
/* HTMX loading indicator — htmx toggles .htmx-request on the element */
|
||||
.ae-loading {
|
||||
font-size: 0.625rem;
|
||||
color: #94A3B8;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.ae-loading.htmx-request { opacity: 1; }
|
||||
|
||||
/* Responsive: stack on narrow screens */
|
||||
@media (max-width: 900px) {
|
||||
.ae-split { grid-template-columns: 1fr; }
|
||||
.ae-pane--preview { display: none; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 48rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">← Back to articles</a>
|
||||
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
|
||||
<div class="ae-shell">
|
||||
|
||||
<form method="post" class="card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Toolbar -->
|
||||
<div class="ae-toolbar">
|
||||
<a href="{{ url_for('admin.articles') }}" class="ae-toolbar__back">← Articles</a>
|
||||
<div class="ae-toolbar__sep"></div>
|
||||
<span class="ae-toolbar__title">
|
||||
{% if editing %}{{ data.get('title', 'Edit Article') }}{% else %}New Article{% endif %}
|
||||
</span>
|
||||
{% if editing %}
|
||||
<span class="ae-toolbar__status ae-toolbar__status--{{ data.get('status', 'draft') }}">
|
||||
{{ data.get('status', 'draft') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<button type="button" class="ae-toolbar__toggle"
|
||||
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
|
||||
<button form="ae-form" type="submit" class="btn btn-sm">
|
||||
{% if editing %}Save Changes{% else %}Create Article{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="title">Title</label>
|
||||
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
|
||||
<!-- Form wraps everything below the toolbar -->
|
||||
<form id="ae-form" method="post" style="display:contents;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Metadata strip -->
|
||||
<div class="ae-meta">
|
||||
<div class="ae-meta__row">
|
||||
<div class="ae-field ae-field--flex3">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}"
|
||||
required placeholder="Article title…">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
|
||||
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}>
|
||||
<div class="ae-field ae-field--flex2">
|
||||
<label for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}"
|
||||
placeholder="auto-generated" {% if editing %}readonly{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="url_path">URL Path</label>
|
||||
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
|
||||
placeholder="e.g. /padel-court-cost-miami">
|
||||
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="meta_description">Meta Description</label>
|
||||
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
|
||||
class="form-input" maxlength="160">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="country">Country</label>
|
||||
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
|
||||
placeholder="e.g. US">
|
||||
<div class="ae-field ae-field--flex2">
|
||||
<label for="url_path">URL Path</label>
|
||||
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}"
|
||||
placeholder="/slug">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="region">Region</label>
|
||||
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input"
|
||||
placeholder="e.g. North America">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="og_image_url">OG Image URL</label>
|
||||
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="body">Body (Markdown)</label>
|
||||
<textarea id="body" name="body" rows="20" class="form-input"
|
||||
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
|
||||
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="language">Language</label>
|
||||
<select id="language" name="language" class="form-input">
|
||||
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>English (en)</option>
|
||||
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>German (de)</option>
|
||||
<div class="ae-field ae-field--fixed80">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
<option value="en" {% if data.get('language', 'en') == 'en' %}selected{% endif %}>EN</option>
|
||||
<option value="de" {% if data.get('language') == 'de' %}selected{% endif %}>DE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="status">Status</label>
|
||||
<select id="status" name="status" class="form-input">
|
||||
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option>
|
||||
<div class="ae-field ae-field--fixed120">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="draft" {% if data.get('status', 'draft') == 'draft' %}selected{% endif %}>Draft</option>
|
||||
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="published_at">Publish Date</label>
|
||||
</div>
|
||||
<div class="ae-meta__row">
|
||||
<div class="ae-field ae-field--flex3">
|
||||
<label for="meta_description">Meta Description</label>
|
||||
<input type="text" id="meta_description" name="meta_description"
|
||||
value="{{ data.get('meta_description', '') }}" maxlength="160"
|
||||
placeholder="160 chars max…">
|
||||
</div>
|
||||
<div class="ae-field ae-field--flex1">
|
||||
<label for="country">Country</label>
|
||||
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}"
|
||||
placeholder="e.g. US">
|
||||
</div>
|
||||
<div class="ae-field ae-field--flex1">
|
||||
<label for="region">Region</label>
|
||||
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}"
|
||||
placeholder="e.g. North America">
|
||||
</div>
|
||||
<div class="ae-field ae-field--flex2">
|
||||
<label for="og_image_url">OG Image URL</label>
|
||||
<input type="text" id="og_image_url" name="og_image_url"
|
||||
value="{{ data.get('og_image_url', '') }}">
|
||||
</div>
|
||||
<div class="ae-field ae-field--fixed160">
|
||||
<label for="published_at">Publish Date</label>
|
||||
<input type="datetime-local" id="published_at" name="published_at"
|
||||
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input">
|
||||
<p class="form-hint">Leave blank for now. Future date = scheduled.</p>
|
||||
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split: editor | preview -->
|
||||
<div class="ae-split">
|
||||
|
||||
<!-- Left — Markdown editor -->
|
||||
<div class="ae-pane ae-pane--editor">
|
||||
<div class="ae-pane__header">
|
||||
<span class="ae-pane__label">Markdown</span>
|
||||
<span class="ae-pane__hint">[scenario:slug] · [product:slug]</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="body" name="body"
|
||||
class="ae-editor"
|
||||
{% if not editing %}required{% endif %}
|
||||
placeholder="Start writing in Markdown…"
|
||||
hx-post="{{ url_for('admin.article_preview') }}"
|
||||
hx-trigger="input delay:500ms"
|
||||
hx-target="#ae-preview-content"
|
||||
hx-include="[name=csrf_token]"
|
||||
hx-indicator="#ae-loading"
|
||||
>{{ data.get('body', '') }}</textarea>
|
||||
<div class="ae-footer">
|
||||
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Right — Rendered preview -->
|
||||
<div class="ae-pane ae-pane--preview">
|
||||
<div class="ae-pane__header">
|
||||
<span class="ae-pane__label">Preview</span>
|
||||
<span id="ae-loading" class="ae-loading">Rendering…</span>
|
||||
</div>
|
||||
<div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
|
||||
{% if preview_doc %}
|
||||
<iframe
|
||||
srcdoc="{{ preview_doc | e }}"
|
||||
style="flex:1;width:100%;border:none;display:block;"
|
||||
sandbox="allow-same-origin"
|
||||
title="Article preview"
|
||||
></iframe>
|
||||
{% else %}
|
||||
<p class="preview-placeholder">Start writing to see a preview.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var textarea = document.getElementById('body');
|
||||
var counter = document.getElementById('ae-wordcount');
|
||||
function updateCount() {
|
||||
var text = textarea.value.trim();
|
||||
var count = text ? text.split(/\s+/).length : 0;
|
||||
counter.textContent = count + (count === 1 ? ' word' : ' words');
|
||||
}
|
||||
textarea.addEventListener('input', updateCount);
|
||||
updateCount();
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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">
|
||||
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display:inline" hx-boost="true">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<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>
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
hx-confirm="Rebuild all articles? This will re-render every article from its template.">Rebuild All</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
@@ -69,8 +70,105 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Bulk action bar #}
|
||||
<form id="article-bulk-form" style="display:none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="article_ids" id="article-bulk-ids" value="">
|
||||
<input type="hidden" name="action" id="article-bulk-action" value="">
|
||||
<input type="hidden" name="search" value="{{ current_search }}">
|
||||
<input type="hidden" name="status" value="{{ current_status }}">
|
||||
<input type="hidden" name="template" value="{{ current_template }}">
|
||||
<input type="hidden" name="language" value="{{ current_language }}">
|
||||
</form>
|
||||
<div id="article-bulk-bar" class="card mb-4" style="padding:0.75rem 1.25rem;display:none;align-items:center;gap:1rem;background:#EFF6FF;border:1px solid #BFDBFE;">
|
||||
<span id="article-bulk-count" class="text-sm font-semibold text-navy">0 selected</span>
|
||||
<select id="article-bulk-action-select" class="form-input" style="min-width:140px;padding:0.25rem 0.5rem;font-size:0.8125rem">
|
||||
<option value="">Action…</option>
|
||||
<option value="publish">Publish</option>
|
||||
<option value="unpublish">Unpublish</option>
|
||||
<option value="toggle_noindex">Toggle noindex</option>
|
||||
<option value="rebuild">Rebuild</option>
|
||||
<option value="delete">Delete</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm" onclick="submitArticleBulk()">Apply</button>
|
||||
<button type="button" class="btn-outline btn-sm" onclick="clearArticleSelection()">Clear</button>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<div id="article-results">
|
||||
{% include "admin/partials/article_results.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const articleSelectedIds = new Set();
|
||||
|
||||
function toggleArticleSelect(id, checked) {
|
||||
if (checked) articleSelectedIds.add(id);
|
||||
else articleSelectedIds.delete(id);
|
||||
updateArticleBulkBar();
|
||||
}
|
||||
|
||||
function toggleArticleGroupSelect(checkbox) {
|
||||
var ids = (checkbox.dataset.ids || '').split(',').map(Number).filter(Boolean);
|
||||
ids.forEach(function(id) {
|
||||
if (checkbox.checked) articleSelectedIds.add(id);
|
||||
else articleSelectedIds.delete(id);
|
||||
});
|
||||
updateArticleBulkBar();
|
||||
}
|
||||
|
||||
function clearArticleSelection() {
|
||||
articleSelectedIds.clear();
|
||||
document.querySelectorAll('.article-checkbox').forEach(function(cb) { cb.checked = false; });
|
||||
var selectAll = document.getElementById('article-select-all');
|
||||
if (selectAll) selectAll.checked = false;
|
||||
updateArticleBulkBar();
|
||||
}
|
||||
|
||||
function updateArticleBulkBar() {
|
||||
var bar = document.getElementById('article-bulk-bar');
|
||||
var count = document.getElementById('article-bulk-count');
|
||||
var ids = document.getElementById('article-bulk-ids');
|
||||
bar.style.display = articleSelectedIds.size > 0 ? 'flex' : 'none';
|
||||
count.textContent = articleSelectedIds.size + ' selected';
|
||||
ids.value = Array.from(articleSelectedIds).join(',');
|
||||
}
|
||||
|
||||
function submitArticleBulk() {
|
||||
var action = document.getElementById('article-bulk-action-select').value;
|
||||
if (!action) return;
|
||||
if (articleSelectedIds.size === 0) return;
|
||||
|
||||
function doSubmit() {
|
||||
document.getElementById('article-bulk-action').value = action;
|
||||
htmx.ajax('POST', '{{ url_for("admin.articles_bulk") }}', {
|
||||
source: document.getElementById('article-bulk-form'),
|
||||
target: '#article-results',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
clearArticleSelection();
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
showConfirm('Delete ' + articleSelectedIds.size + ' articles? This cannot be undone.').then(function(ok) {
|
||||
if (ok) doSubmit();
|
||||
});
|
||||
} else {
|
||||
doSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'article-results') {
|
||||
document.querySelectorAll('.article-checkbox').forEach(function(cb) {
|
||||
if (cb.dataset.ids) {
|
||||
var ids = cb.dataset.ids.split(',').map(Number).filter(Boolean);
|
||||
cb.checked = ids.length > 0 && ids.every(function(id) { return articleSelectedIds.has(id); });
|
||||
} else {
|
||||
cb.checked = articleSelectedIds.has(Number(cb.dataset.id));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
<td class="text-sm">{{ c.email if c.email is defined else (c.get('email', '-') if c is mapping else '-') }}</td>
|
||||
<td class="mono text-sm">{{ (c.created_at if c.created_at is defined else (c.get('created_at', '-') if c is mapping else '-'))[:16] if c else '-' }}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline">
|
||||
<form method="post" action="{{ url_for('admin.audience_contact_remove', audience_id=audience.audience_id) }}" style="display:inline" hx-boost="true">
|
||||
<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="button" class="btn-outline btn-sm" style="color:#DC2626" onclick="confirmAction('Remove this contact from the audience?', this.closest('form'))">Remove</button>
|
||||
<button type="submit" class="btn-outline btn-sm" style="color:#DC2626"
|
||||
hx-confirm="Remove this contact from the audience?">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -40,8 +40,10 @@
|
||||
.admin-subnav {
|
||||
display: flex; align-items: stretch; padding: 0 2rem;
|
||||
background: #fff; border-bottom: 1px solid #E2E8F0;
|
||||
flex-shrink: 0; overflow-x: auto; gap: 0;
|
||||
flex-shrink: 0; overflow-x: auto; overflow-y: hidden; gap: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.admin-subnav::-webkit-scrollbar { display: none; }
|
||||
.admin-subnav a {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 0 1px; margin: 0 13px 0 0; height: 42px;
|
||||
@@ -99,6 +101,7 @@
|
||||
'suppliers': 'suppliers',
|
||||
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
||||
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
||||
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', 'affiliate_programs': 'affiliate',
|
||||
'billing': 'billing',
|
||||
'seo': 'analytics',
|
||||
'pipeline': 'pipeline',
|
||||
@@ -149,6 +152,11 @@
|
||||
Billing
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016 2.993 2.993 0 0 0 2.25-1.016 3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
|
||||
Affiliate
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.seo') }}" class="{% if active_section == 'analytics' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
Analytics
|
||||
@@ -196,6 +204,12 @@
|
||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">Audiences</a>
|
||||
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">Outreach</a>
|
||||
</nav>
|
||||
{% elif active_section == 'affiliate' %}
|
||||
<nav class="admin-subnav">
|
||||
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
|
||||
<a href="{{ url_for('admin.affiliate_programs') }}" class="{% if admin_page == 'affiliate_programs' %}active{% endif %}">Programs</a>
|
||||
</nav>
|
||||
{% elif active_section == 'system' %}
|
||||
<nav class="admin-subnav">
|
||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">Users</a>
|
||||
@@ -214,21 +228,29 @@
|
||||
|
||||
<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>
|
||||
<form method="dialog" class="dialog-actions">
|
||||
<button value="cancel" class="btn-outline btn-sm">Cancel</button>
|
||||
<button value="ok" class="btn btn-sm">Confirm</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>
|
||||
function confirmAction(message, form) {
|
||||
function showConfirm(message) {
|
||||
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();
|
||||
return new Promise(function(resolve) {
|
||||
dialog.addEventListener('close', function() {
|
||||
resolve(dialog.returnValue === 'ok');
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:confirm', function(evt) {
|
||||
if (!evt.detail.question) return;
|
||||
evt.preventDefault();
|
||||
showConfirm(evt.detail.question).then(function(ok) {
|
||||
if (ok) evt.detail.issueRequest(true);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user