Compare commits
25 Commits
v202602282
...
v202603011
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b54f2d544 | ||
|
|
08bd2b2989 | ||
|
|
81a57db272 | ||
|
|
f92d863781 | ||
|
|
a3dd37b1be | ||
|
|
e5cbcf462e | ||
|
|
169092c8ea | ||
|
|
6ae16f6c1f | ||
|
|
8b33daa4f3 | ||
|
|
a898a06575 | ||
|
|
219554b7cb | ||
|
|
1aedf78ec6 | ||
|
|
8f2ffd432b | ||
|
|
c9dec066f7 | ||
|
|
fea4f85da3 | ||
|
|
2590020014 | ||
|
|
a72f7721bb | ||
|
|
849dc8359c | ||
|
|
ec839478c3 | ||
|
|
47acf4d3df | ||
|
|
53117094ee | ||
|
|
6076a0b30f | ||
|
|
8dbbd0df05 | ||
|
|
b1eeb0a0ac | ||
|
|
6aae92fc58 |
@@ -58,7 +58,7 @@ NTFY_TOKEN=
|
|||||||
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
|
#ENC[AES256_GCM,data:BCyQYjRnTx8yW9A=,iv:4OPCP+xzRLUJrpoFewVnbZRKnZH4sAbV76SM//2k5wU=,tag:HxwEp7VFVZUN/VjPiL/+Vw==,type:comment]
|
||||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,type:str]
|
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:YWM=,iv:iY5+uMazLAFdwyLT7Gr7MaF1QHBIgHuoi6nF2VbSsOA=,tag:dc6AmuJdTQ55gVe16uzs6A==,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_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:X6xpxz5u8Xh3OXjkIz3UwqH847qLvY9cVWVktW5B+lqhmXAKTzoTzHds8vlRGJf5Up9Yx44XcigbvuK33ZJDSq9ovkAIbY55OK4=,iv:3hHyFD+H9HMzQ/27bPjGr59+7yWmEneUdN9XPQasCig=,tag:oBXsSuV5idB7HqNrNOruwg==,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]
|
WEBSHARE_DOWNLOAD_URL=ENC[AES256_GCM,data:1D9VRZ3MCXPQWfiMH8+CLcrxeYnVVcQgZDvt5kltvbSTuSHQ2hHDmZpBkTOMIBJnw4JLZ2JQKHgG4OaYDtsM2VltFPnfwaRgVI9G5PSenR3o4PeQmYO1AqWOmjn19jPxNXRhEXdupP9UT+xQNXoBJsl6RR20XOpMA5AipUHmSjD0UIKXoZLU,iv:uWUkAydac//qrOTPUThuOLKAKXK4xcZmK9qBVFwpqt4=,tag:1vYhukBW9kEuSXCLAiZZmQ==,type:str]
|
||||||
CIRCUIT_BREAKER_THRESHOLD=
|
CIRCUIT_BREAKER_THRESHOLD=
|
||||||
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
|
#ENC[AES256_GCM,data:ZcX/OEbrMfKizIQYq3CYGnvzeTEX7KsmQaz2+Jj1rG5tbTy2aljQBIEkjtiwuo8NsNAD+FhIGRGVfBmKe1CAKME1MuiCbgSG,iv:4BSkeD3jZFawP09qECcqyuiWcDnCNSgbIjBATYhazq4=,tag:Ep1d2Uk700MOlWcLWaQ/ig==,type:comment]
|
||||||
@@ -71,7 +71,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]
|
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_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_age__list_0__map_recipient=age1f5002gj4s78jju45jd28kuejtcfhn5cdujz885fl7z2p9ym68pnsgky87a
|
||||||
sops_lastmodified=2026-02-28T15:50:46Z
|
sops_lastmodified=2026-03-01T13:26:08Z
|
||||||
sops_mac=ENC[AES256_GCM,data:HiLZTLa+p3mqa4hw+tKOK27F/bsJOy4jmDi8MHToi6S7tRfBA/TzcEzXvXUIkkwAixN73NQHvBVeRnbcEsApVpkaxH1OqnjvvyT+B3YFkTEtxczaKGWlCvbqFZNmXYsFvGR9njaWYWsTQPkRIjrroXrSrhr7uxC8F40v7ByxJKo=,iv:qj2IpzWRIh/mM1HtjjkNbyFuhtORKXslVnf/vdEC9Uw=,tag:fr9CZsL74HxRJLXn9eS0xQ==,type:str]
|
sops_mac=ENC[AES256_GCM,data:WmbT6tCUEoCDyKu673NQoJNzmCiilpG8yDVGl6ObxTOYleWt+1DVdPS+XUV+0Wd4bfkEhGTEfXAyy+wfoCVfYnenMuDGjXUUdsvqrOX6nnNCJ8nIntL46LfbRsbVrU6eeYGu/TaTyfouWjkk6pqlxffNSS6rrEFNZE4Q+v58+EI=,iv:TuCEmK6YJXsYISbN4mbuVbS6OvUNuhPRLstjjNkkrPk=,tag:hWLS036q7H5lMNpR6gZBVA==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
@@ -32,10 +32,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_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_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]
|
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]
|
#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]
|
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]
|
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
||||||
@@ -43,8 +39,8 @@ ALERT_WEBHOOK_URL=ENC[AES256_GCM,data:4sXQk8zklruC525J279TUUatdDJQ43qweuoPhtpI82
|
|||||||
NTFY_TOKEN=ENC[AES256_GCM,data:YlOxhsRJ8P1y4kk6ugWm41iyRCsM6oAWjvbU9lGcD0A=,iv:JZXOvi3wTOPV9A46c7fMiqbszNCvXkOgh9i/H1hob24=,tag:8xnPimgy7sesOAnxhaXmpg==,type:str]
|
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]
|
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]
|
#ENC[AES256_GCM,data:hzAZvCWc4RTk290=,iv:RsSI4OpAOQGcFVpfXDZ6t705yWmlO0JEWwWF5uQu9As=,tag:UPqFtA2tXiSa0vzJAv8qXg==,type:comment]
|
||||||
PROXY_URLS_RESIDENTIAL=ENC[AES256_GCM,data:x/F0toXDc8stsUNxaepCmxq1+WuacqqPtdc+R5mxTwcAzsKxCdwt8KpBZWMvz7ku4tHDGsKD949QAX2ANXP9oCMTgW0=,iv:6G9gE9/v7GaYj8aqVTmMrpw6AcQK9yMSCAohNdAD1Ws=,tag:2Jimr1ldVSfkh8LPEwdN3w==,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:6BfXBYmyHpgZU/kJWpZLf8eH5VowVK1n0r6GzFTNAx/OmyaaS1RZVPC1JPkPBnTwEmo0WHYRW8uiUdkABmH9F5ZqqlsAesyfW7zvU9r7yD+D7w==,iv:3CBn2qCoTueQy8xVcQqZS4E3F0qoFYnNbzTZTpJ1veo=,tag:wC3Ecl4uNTwPiT23ATvRZg==,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]
|
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:vdEZ,iv:+eTNQO+s/SsVDBLg1/+fneMzEEsFkuEFxo/FcVV+mWc=,tag:i/EPwi/jOoWl3xW8H0XMdw==,type:str]
|
PROXY_CONCURRENCY=ENC[AES256_GCM,data:vdEZ,iv:+eTNQO+s/SsVDBLg1/+fneMzEEsFkuEFxo/FcVV+mWc=,tag:i/EPwi/jOoWl3xW8H0XMdw==,type:str]
|
||||||
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
|
RECHECK_WINDOW_MINUTES=ENC[AES256_GCM,data:L2s=,iv:fV3mCKmK5fxUmIWRePELBDAPTb8JZqasVIhnAl55kYw=,tag:XL+PO6sblz/7WqHC3dtk1w==,type:str]
|
||||||
@@ -62,7 +58,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
|||||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
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_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_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||||
sops_lastmodified=2026-02-28T17:03:44Z
|
sops_lastmodified=2026-03-01T13:25:41Z
|
||||||
sops_mac=ENC[AES256_GCM,data:IQ9jpRxVUssaMK+qFcM3nPdzXHkiqp6E+DhEey1TfqUu5GCBNsWeVy9m9A6p9RWhu2NtJV7aKdUeqneuMtD1q5Tnm6L96zuyot2ESnx2N2ssD9ilrDauQxoBJcrJVnGV61CgaCz9458w8BuVUZydn3MoHeRaU7bOBBzQlTI6vZk=,iv:qHqdt3av/KZRQHr/OS/9KdAJUgKlKEDgan7qI3Zzkck=,tag:fOvdO9iRTTF1Siobu2mLqg==,type:str]
|
sops_mac=ENC[AES256_GCM,data:EL9Bgo0pWWECeHaaM1bHtkvwBgBmS3P2cX+6oahHKmLEJLI7P7fiomP7G8SdrfUyNpZaP9d4LlfwZSuCPqH6rP8jzF67oNkfXfd/xK4OW2U2TqSvouCMzlhqVQgS4HHl5EgvOI488WEIZko7KK2A1rxnpkm8C29WG9d9G64LKvw=,iv:XzsNm3CXnlC6SIef63BdddALjGustp8czHQCWOtjXBQ=,tag:zll0db6K1+M4brOpfVWnhg==,type:str]
|
||||||
sops_unencrypted_suffix=_unencrypted
|
sops_unencrypted_suffix=_unencrypted
|
||||||
sops_version=3.12.1
|
sops_version=3.12.1
|
||||||
|
|||||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
- **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
|
### 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.
|
- **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/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Padelnomics — Project Tracker
|
# Padelnomics — Project Tracker
|
||||||
|
|
||||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||||
> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking).
|
> Last updated: 2026-02-28 (Affiliate programs management — centralised retailer config + URL template assembly).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -133,6 +133,7 @@
|
|||||||
- [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] **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] **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 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
|
### SEO & Legal
|
||||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
---
|
---
|
||||||
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
|
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
|
||||||
slug: padelschlaeger-fortgeschrittene-de
|
slug: padelschlaeger-fortgeschrittene-de
|
||||||
language: de
|
language: de
|
||||||
url_path: /padelschlaeger-fortgeschrittene
|
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."
|
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
|
# Padelschläger für Fortgeschrittene: Die besten Modelle 2026
|
||||||
|
|
||||||
<!-- TODO: Einleitung — wann ist man bereit für einen Fortgeschrittenenschläger? -->
|
<!-- 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.
|
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
|
## Top-Schläger für Fortgeschrittene im Überblick
|
||||||
|
|
||||||
[product-group:racket]
|
[product-group:racket]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Carbon, Kevlar, Glasfaser: Was steckt drin?
|
## Carbon, Kevlar, Glasfaser: Was steckt drin?
|
||||||
|
|
||||||
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
|
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
|
||||||
|
|
||||||
### Carbon-Rahmen
|
### Carbon-Rahmen
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
### 3K vs. 12K Carbon
|
### 3K vs. 12K Carbon
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
### Kevlar-Einlagen
|
### Kevlar-Einlagen
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- TODO -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testbericht: Unser Empfehlungsschläger
|
## Testbericht: Unser Empfehlungsschläger
|
||||||
|
|
||||||
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
|
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
|
||||||
|
|
||||||
<!-- TODO: Praxistest -->
|
<!-- TODO: Praxistest -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Häufige Fragen
|
## Häufige Fragen
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
|
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- 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.
|
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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
|
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
|
||||||
|
|
||||||
<!-- TODO -->
|
<!-- 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.
|
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>
|
</details>
|
||||||
@@ -60,9 +60,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -82,9 +83,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
@@ -98,9 +100,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
@@ -115,9 +118,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -137,9 +141,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
@@ -153,9 +158,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/app/data/app.db
|
- DATABASE_PATH=/app/data/app.db
|
||||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||||
|
- LANDING_DIR=/app/data/pipeline/landing
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
- /data/padelnomics:/app/data/pipeline:ro
|
||||||
networks:
|
networks:
|
||||||
- net
|
- net
|
||||||
|
|
||||||
|
|||||||
@@ -213,9 +213,10 @@ def _fetch_venues_parallel(
|
|||||||
completed_count = 0
|
completed_count = 0
|
||||||
lock = threading.Lock()
|
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"]()
|
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:
|
with ThreadPoolExecutor(max_workers=worker_count) as pool:
|
||||||
for batch_start in range(0, len(tenant_ids), PARALLEL_BATCH_SIZE):
|
for batch_start in range(0, len(tenant_ids), PARALLEL_BATCH_SIZE):
|
||||||
@@ -231,17 +232,17 @@ def _fetch_venues_parallel(
|
|||||||
batch_futures = {pool.submit(_worker, tid): tid for tid in batch}
|
batch_futures = {pool.submit(_worker, tid): tid for tid in batch}
|
||||||
|
|
||||||
for future in as_completed(batch_futures):
|
for future in as_completed(batch_futures):
|
||||||
result = future.result()
|
proxy_url, result = future.result()
|
||||||
with lock:
|
with lock:
|
||||||
completed_count += 1
|
completed_count += 1
|
||||||
if result is not None:
|
if result is not None:
|
||||||
venues_data.append(result)
|
venues_data.append(result)
|
||||||
cycler["record_success"]()
|
cycler["record_success"](proxy_url)
|
||||||
if on_result is not None:
|
if on_result is not None:
|
||||||
on_result(result)
|
on_result(result)
|
||||||
else:
|
else:
|
||||||
venues_errored += 1
|
venues_errored += 1
|
||||||
cycler["record_failure"]()
|
cycler["record_failure"](proxy_url)
|
||||||
|
|
||||||
if completed_count % 500 == 0:
|
if completed_count % 500 == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -336,16 +337,17 @@ def extract(
|
|||||||
else:
|
else:
|
||||||
logger.info("Serial mode: 1 worker, %d venues", len(venues_to_process))
|
logger.info("Serial mode: 1 worker, %d venues", len(venues_to_process))
|
||||||
for i, tenant_id in enumerate(venues_to_process):
|
for i, tenant_id in enumerate(venues_to_process):
|
||||||
|
proxy_url = cycler["next_proxy"]()
|
||||||
result = _fetch_venue_availability(
|
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:
|
if result is not None:
|
||||||
new_venues_data.append(result)
|
new_venues_data.append(result)
|
||||||
cycler["record_success"]()
|
cycler["record_success"](proxy_url)
|
||||||
_on_result(result)
|
_on_result(result)
|
||||||
else:
|
else:
|
||||||
venues_errored += 1
|
venues_errored += 1
|
||||||
cycler["record_failure"]()
|
cycler["record_failure"](proxy_url)
|
||||||
if cycler["is_exhausted"]():
|
if cycler["is_exhausted"]():
|
||||||
logger.error("All proxy tiers exhausted — writing partial results")
|
logger.error("All proxy tiers exhausted — writing partial results")
|
||||||
break
|
break
|
||||||
@@ -500,13 +502,14 @@ def extract_recheck(
|
|||||||
venues_data = []
|
venues_data = []
|
||||||
venues_errored = 0
|
venues_errored = 0
|
||||||
for tid in venues_to_recheck:
|
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:
|
if result is not None:
|
||||||
venues_data.append(result)
|
venues_data.append(result)
|
||||||
cycler["record_success"]()
|
cycler["record_success"](proxy_url)
|
||||||
else:
|
else:
|
||||||
venues_errored += 1
|
venues_errored += 1
|
||||||
cycler["record_failure"]()
|
cycler["record_failure"](proxy_url)
|
||||||
if cycler["is_exhausted"]():
|
if cycler["is_exhausted"]():
|
||||||
logger.error("All proxy tiers exhausted — writing partial recheck results")
|
logger.error("All proxy tiers exhausted — writing partial recheck results")
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ API notes (discovered 2026-02):
|
|||||||
- `size=100` is the maximum effective page size
|
- `size=100` is the maximum effective page size
|
||||||
- ~14K venues globally as of Feb 2026
|
- ~14K venues globally as of Feb 2026
|
||||||
|
|
||||||
Parallel mode: when PROXY_URLS is set, fires batch_size = len(proxy_urls)
|
Parallel mode: when proxy tiers are configured, fires BATCH_SIZE pages
|
||||||
pages concurrently. Each page gets its own fresh session + proxy. Pages beyond
|
concurrently. Each page gets its own fresh session + proxy from the tiered
|
||||||
the last one return empty lists (safe — just triggers the done condition).
|
cycler. On failure the cycler escalates through free → datacenter →
|
||||||
Without proxies, falls back to single-threaded with THROTTLE_SECONDS between
|
residential tiers. Without proxies, falls back to single-threaded with
|
||||||
pages.
|
THROTTLE_SECONDS between pages.
|
||||||
|
|
||||||
Rate: 1 req / 2 s per IP (see docs/data-sources-inventory.md §1.2).
|
Rate: 1 req / 2 s per IP (see docs/data-sources-inventory.md §1.2).
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ Landing: {LANDING_DIR}/playtomic/{year}/{month}/tenants.jsonl.gz
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
@@ -31,7 +32,7 @@ from pathlib import Path
|
|||||||
import niquests
|
import niquests
|
||||||
|
|
||||||
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy
|
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging, ua_for_proxy
|
||||||
from .proxy import load_proxy_tiers, make_round_robin_cycler
|
from .proxy import load_proxy_tiers, make_tiered_cycler
|
||||||
from .utils import compress_jsonl_atomic, landing_path
|
from .utils import compress_jsonl_atomic, landing_path
|
||||||
|
|
||||||
logger = setup_logging("padelnomics.extract.playtomic_tenants")
|
logger = setup_logging("padelnomics.extract.playtomic_tenants")
|
||||||
@@ -42,6 +43,9 @@ PLAYTOMIC_TENANTS_URL = "https://api.playtomic.io/v1/tenants"
|
|||||||
THROTTLE_SECONDS = 2
|
THROTTLE_SECONDS = 2
|
||||||
PAGE_SIZE = 100
|
PAGE_SIZE = 100
|
||||||
MAX_PAGES = 500 # safety bound — ~50K venues max, well above current ~14K
|
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]]:
|
def _fetch_one_page(proxy_url: str | None, page: int) -> tuple[int, list[dict]]:
|
||||||
@@ -61,22 +65,57 @@ def _fetch_one_page(proxy_url: str | None, page: int) -> tuple[int, list[dict]]:
|
|||||||
return (page, tenants)
|
return (page, tenants)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_pages_parallel(pages: list[int], next_proxy) -> list[tuple[int, list[dict]]]:
|
def _fetch_page_via_cycler(cycler: dict, page: int) -> tuple[int, list[dict]]:
|
||||||
"""Fetch multiple pages concurrently. Returns [(page_num, tenants_list), ...]."""
|
"""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:
|
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)]
|
return [f.result() for f in as_completed(futures)]
|
||||||
|
|
||||||
|
|
||||||
def extract(
|
def extract(
|
||||||
landing_dir: Path,
|
landing_dir: Path,
|
||||||
year_month: str, # noqa: ARG001 — unused; tenants uses ISO week partition instead
|
year_month: str, # noqa: ARG001 — unused; tenants uses daily partition instead
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
session: niquests.Session,
|
session: niquests.Session,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Fetch all Playtomic venues via global pagination. Returns run metrics.
|
"""Fetch all Playtomic venues via global pagination. Returns run metrics.
|
||||||
|
|
||||||
Partitioned by ISO week (e.g. 2026/W09) so each weekly run produces a
|
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
|
fresh file. _load_tenant_ids() in playtomic_availability globs across all
|
||||||
partitions and picks the most recent one.
|
partitions and picks the most recent one.
|
||||||
"""
|
"""
|
||||||
@@ -89,12 +128,16 @@ def extract(
|
|||||||
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||||
|
|
||||||
tiers = load_proxy_tiers()
|
tiers = load_proxy_tiers()
|
||||||
all_proxies = [url for tier in tiers for url in tier]
|
cycler = make_tiered_cycler(tiers, CIRCUIT_BREAKER_THRESHOLD) if tiers else None
|
||||||
next_proxy = make_round_robin_cycler(all_proxies) if all_proxies else None
|
batch_size = BATCH_SIZE if cycler else 1
|
||||||
batch_size = len(all_proxies) if all_proxies else 1
|
|
||||||
|
|
||||||
if next_proxy:
|
if cycler:
|
||||||
logger.info("Parallel mode: %d pages per batch (%d proxies across %d tier(s))", batch_size, len(all_proxies), len(tiers))
|
logger.info(
|
||||||
|
"Parallel mode: %d pages/batch, %d tier(s), threshold=%d",
|
||||||
|
batch_size,
|
||||||
|
cycler["tier_count"](),
|
||||||
|
CIRCUIT_BREAKER_THRESHOLD,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Serial mode: 1 page at a time (no proxies)")
|
logger.info("Serial mode: 1 page at a time (no proxies)")
|
||||||
|
|
||||||
@@ -104,15 +147,33 @@ def extract(
|
|||||||
done = False
|
done = False
|
||||||
|
|
||||||
while not done and page < MAX_PAGES:
|
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)
|
batch_end = min(page + batch_size, MAX_PAGES)
|
||||||
pages_to_fetch = list(range(page, batch_end))
|
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(
|
logger.info(
|
||||||
"Fetching pages %d-%d in parallel (%d workers, total so far: %d)",
|
"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:
|
else:
|
||||||
# Serial: reuse the shared session, throttle between pages
|
# Serial: reuse the shared session, throttle between pages
|
||||||
page_num = pages_to_fetch[0]
|
page_num = pages_to_fetch[0]
|
||||||
@@ -126,7 +187,7 @@ def extract(
|
|||||||
)
|
)
|
||||||
results = [(page_num, tenants)]
|
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):
|
for p, tenants in sorted(results):
|
||||||
new_count = 0
|
new_count = 0
|
||||||
for tenant in tenants:
|
for tenant in tenants:
|
||||||
@@ -137,7 +198,11 @@ def extract(
|
|||||||
new_count += 1
|
new_count += 1
|
||||||
|
|
||||||
logger.info(
|
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
|
# Last page — fewer than PAGE_SIZE results means we've exhausted the list
|
||||||
@@ -146,7 +211,7 @@ def extract(
|
|||||||
break
|
break
|
||||||
|
|
||||||
page = batch_end
|
page = batch_end
|
||||||
if not next_proxy:
|
if not cycler:
|
||||||
time.sleep(THROTTLE_SECONDS)
|
time.sleep(THROTTLE_SECONDS)
|
||||||
|
|
||||||
# Write each tenant as a JSONL line, then compress atomically
|
# Write each tenant as a JSONL line, then compress atomically
|
||||||
|
|||||||
@@ -88,8 +88,14 @@ def load_proxy_tiers() -> list[list[str]]:
|
|||||||
for var in ("PROXY_URLS_DATACENTER", "PROXY_URLS_RESIDENTIAL"):
|
for var in ("PROXY_URLS_DATACENTER", "PROXY_URLS_RESIDENTIAL"):
|
||||||
raw = os.environ.get(var, "")
|
raw = os.environ.get(var, "")
|
||||||
urls = [u.strip() for u in raw.split(",") if u.strip()]
|
urls = [u.strip() for u in raw.split(",") if u.strip()]
|
||||||
if urls:
|
valid = []
|
||||||
tiers.append(urls)
|
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
|
return tiers
|
||||||
|
|
||||||
@@ -134,8 +140,8 @@ def make_sticky_selector(proxy_urls: list[str]):
|
|||||||
return select_proxy
|
return select_proxy
|
||||||
|
|
||||||
|
|
||||||
def make_tiered_cycler(tiers: list[list[str]], threshold: int) -> dict:
|
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.
|
"""Thread-safe N-tier proxy cycler with circuit breaker and per-proxy dead tracking.
|
||||||
|
|
||||||
Uses tiers[0] until consecutive failures >= threshold, then escalates
|
Uses tiers[0] until consecutive failures >= threshold, then escalates
|
||||||
to tiers[1], then tiers[2], etc. Once all tiers are exhausted,
|
to tiers[1], then tiers[2], etc. Once all tiers are exhausted,
|
||||||
@@ -144,13 +150,21 @@ def make_tiered_cycler(tiers: list[list[str]], threshold: int) -> dict:
|
|||||||
Failure counter resets on each escalation — the new tier gets a fresh start.
|
Failure counter resets on each escalation — the new tier gets a fresh start.
|
||||||
Once exhausted, further record_failure() calls are no-ops.
|
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.
|
||||||
|
|
||||||
Returns a dict of callables:
|
Returns a dict of callables:
|
||||||
next_proxy() -> str | None — URL from the active tier, or None
|
next_proxy() -> str | None — URL from active tier (skips dead), or None
|
||||||
record_success() -> None — resets consecutive failure counter
|
record_success(proxy_url=None) -> None — resets consecutive failure counter
|
||||||
record_failure() -> bool — True if just escalated to next tier
|
record_failure(proxy_url=None) -> bool — True if just escalated to next tier
|
||||||
is_exhausted() -> bool — True if all tiers exhausted
|
is_exhausted() -> bool — True if all tiers exhausted
|
||||||
active_tier_index() -> int — 0-based index of current tier
|
active_tier_index() -> int — 0-based index of current tier
|
||||||
tier_count() -> int — total number of tiers
|
tier_count() -> int — total number of tiers
|
||||||
|
dead_proxy_count() -> int — number of individual proxies marked dead
|
||||||
|
|
||||||
Edge cases:
|
Edge cases:
|
||||||
Empty tiers list: next_proxy() always returns None, is_exhausted() True.
|
Empty tiers list: next_proxy() always returns None, is_exhausted() True.
|
||||||
@@ -158,28 +172,75 @@ def make_tiered_cycler(tiers: list[list[str]], threshold: int) -> dict:
|
|||||||
"""
|
"""
|
||||||
assert threshold > 0, f"threshold must be positive, got {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 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()
|
lock = threading.Lock()
|
||||||
cycles = [itertools.cycle(t) for t in tiers]
|
cycles = [itertools.cycle(t) for t in tiers]
|
||||||
state = {
|
state = {
|
||||||
"active_tier": 0,
|
"active_tier": 0,
|
||||||
"consecutive_failures": 0,
|
"consecutive_failures": 0,
|
||||||
|
"proxy_failure_counts": {}, # proxy_url -> int
|
||||||
|
"dead_proxies": set(), # proxy URLs marked dead
|
||||||
}
|
}
|
||||||
|
|
||||||
def next_proxy() -> str | None:
|
def next_proxy() -> str | None:
|
||||||
with lock:
|
with lock:
|
||||||
idx = state["active_tier"]
|
# Try each remaining tier (bounded: at most len(tiers) escalations)
|
||||||
if idx >= len(cycles):
|
for _ in range(len(tiers) + 1):
|
||||||
return None
|
idx = state["active_tier"]
|
||||||
return next(cycles[idx])
|
if idx >= len(cycles):
|
||||||
|
return None
|
||||||
|
|
||||||
def record_success() -> None:
|
tier_proxies = tiers[idx]
|
||||||
|
tier_len = len(tier_proxies)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
"All proxies in tier %d are dead — auto-escalating to tier %d/%d",
|
||||||
|
idx + 1,
|
||||||
|
new_idx + 1,
|
||||||
|
len(tiers),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"All proxies in all %d tier(s) are dead — no more fallbacks",
|
||||||
|
len(tiers),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None # safety fallback
|
||||||
|
|
||||||
|
def record_success(proxy_url: str | None = None) -> None:
|
||||||
with lock:
|
with lock:
|
||||||
state["consecutive_failures"] = 0
|
state["consecutive_failures"] = 0
|
||||||
|
if proxy_url is not None:
|
||||||
|
state["proxy_failure_counts"][proxy_url] = 0
|
||||||
|
|
||||||
def record_failure() -> bool:
|
def record_failure(proxy_url: str | None = None) -> bool:
|
||||||
"""Increment failure counter. Returns True if just escalated to next tier."""
|
"""Increment failure counter. Returns True if just escalated to next tier."""
|
||||||
with lock:
|
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"]
|
idx = state["active_tier"]
|
||||||
if idx >= len(tiers):
|
if idx >= len(tiers):
|
||||||
# Already exhausted — no-op
|
# Already exhausted — no-op
|
||||||
@@ -219,6 +280,10 @@ def make_tiered_cycler(tiers: list[list[str]], threshold: int) -> dict:
|
|||||||
def tier_count() -> int:
|
def tier_count() -> int:
|
||||||
return len(tiers)
|
return len(tiers)
|
||||||
|
|
||||||
|
def dead_proxy_count() -> int:
|
||||||
|
with lock:
|
||||||
|
return len(state["dead_proxies"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"next_proxy": next_proxy,
|
"next_proxy": next_proxy,
|
||||||
"record_success": record_success,
|
"record_success": record_success,
|
||||||
@@ -226,4 +291,5 @@ def make_tiered_cycler(tiers: list[list[str]], threshold: int) -> dict:
|
|||||||
"is_exhausted": is_exhausted,
|
"is_exhausted": is_exhausted,
|
||||||
"active_tier_index": active_tier_index,
|
"active_tier_index": active_tier_index,
|
||||||
"tier_count": tier_count,
|
"tier_count": tier_count,
|
||||||
|
"dead_proxy_count": dead_proxy_count,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ schedule = "monthly"
|
|||||||
module = "padelnomics_extract.eurostat"
|
module = "padelnomics_extract.eurostat"
|
||||||
schedule = "monthly"
|
schedule = "monthly"
|
||||||
|
|
||||||
|
[geonames]
|
||||||
|
module = "padelnomics_extract.geonames"
|
||||||
|
schedule = "monthly"
|
||||||
|
|
||||||
[playtomic_tenants]
|
[playtomic_tenants]
|
||||||
module = "padelnomics_extract.playtomic_tenants"
|
module = "padelnomics_extract.playtomic_tenants"
|
||||||
schedule = "daily"
|
schedule = "daily"
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
||||||
--
|
--
|
||||||
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
||||||
-- A bounding-box pre-filter (~0.5°, ≈55km) reduces the cross-join before the
|
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
|
||||||
-- exact sphere distance is computed.
|
-- (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 (
|
MODEL (
|
||||||
name foundation.dim_locations,
|
name foundation.dim_locations,
|
||||||
@@ -147,6 +149,8 @@ padel_courts AS (
|
|||||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||||
),
|
),
|
||||||
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
-- 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 (
|
nearest_padel AS (
|
||||||
SELECT
|
SELECT
|
||||||
l.geoname_id,
|
l.geoname_id,
|
||||||
@@ -158,9 +162,12 @@ nearest_padel AS (
|
|||||||
) AS nearest_padel_court_km
|
) AS nearest_padel_court_km
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN padel_courts p
|
JOIN padel_courts p
|
||||||
-- ~55km bounding box pre-filter to limit cross-join before sphere calc
|
-- ~55km bounding box pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
ON ABS(l.lat - p.lat) < 0.5
|
ON l.lat BETWEEN p.lat - 0.5 AND p.lat + 0.5
|
||||||
AND ABS(l.lon - p.lon) < 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
|
GROUP BY l.geoname_id
|
||||||
),
|
),
|
||||||
-- Padel venues within 5km of each location (counts as "local padel supply")
|
-- Padel venues within 5km of each location (counts as "local padel supply")
|
||||||
@@ -170,24 +177,35 @@ padel_local AS (
|
|||||||
COUNT(*) AS padel_venue_count
|
COUNT(*) AS padel_venue_count
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN padel_courts p
|
JOIN padel_courts p
|
||||||
ON ABS(l.lat - p.lat) < 0.05 -- ~5km bbox pre-filter
|
-- ~5km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
AND ABS(l.lon - p.lon) < 0.05
|
ON l.lat BETWEEN p.lat - 0.05 AND p.lat + 0.05
|
||||||
WHERE ST_Distance_Sphere(
|
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(l.lon, l.lat),
|
||||||
ST_Point(p.lon, p.lat)
|
ST_Point(p.lon, p.lat)
|
||||||
) / 1000.0 <= 5.0
|
) / 1000.0 <= 5.0
|
||||||
GROUP BY l.geoname_id
|
GROUP BY l.geoname_id
|
||||||
),
|
),
|
||||||
-- Tennis courts within 25km of each location (sports culture proxy)
|
-- 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 (
|
tennis_nearby AS (
|
||||||
SELECT
|
SELECT
|
||||||
l.geoname_id,
|
l.geoname_id,
|
||||||
COUNT(*) AS tennis_courts_within_25km
|
COUNT(*) AS tennis_courts_within_25km
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN staging.stg_tennis_courts t
|
JOIN staging.stg_tennis_courts t
|
||||||
ON ABS(l.lat - t.lat) < 0.23 -- ~25km bbox pre-filter
|
-- ~25km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||||
AND ABS(l.lon - t.lon) < 0.23
|
ON l.lat BETWEEN t.lat - 0.23 AND t.lat + 0.23
|
||||||
WHERE ST_Distance_Sphere(
|
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(l.lon, l.lat),
|
||||||
ST_Point(t.lon, t.lat)
|
ST_Point(t.lon, t.lat)
|
||||||
) / 1000.0 <= 25.0
|
) / 1000.0 <= 25.0
|
||||||
|
|||||||
@@ -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/overview → HTMX tab: extraction status, serving freshness, landing stats
|
||||||
/admin/pipeline/extractions → HTMX tab: filterable extraction run history
|
/admin/pipeline/extractions → HTMX tab: filterable extraction run history
|
||||||
/admin/pipeline/extractions/<id>/mark-stale → POST: mark stuck "running" row as failed
|
/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 → HTMX tab: data catalog (tables, columns, sample data)
|
||||||
/admin/pipeline/catalog/<table> → HTMX partial: table detail (columns + sample)
|
/admin/pipeline/catalog/<table> → HTMX partial: table detail (columns + sample)
|
||||||
/admin/pipeline/query → HTMX tab: SQL query editor
|
/admin/pipeline/query → HTMX tab: SQL query editor
|
||||||
@@ -18,6 +20,7 @@ Data sources:
|
|||||||
- analytics.duckdb (DuckDB read-only via analytics.execute_user_query)
|
- analytics.duckdb (DuckDB read-only via analytics.execute_user_query)
|
||||||
- LANDING_DIR/ (filesystem scan for file sizes + dates)
|
- LANDING_DIR/ (filesystem scan for file sizes + dates)
|
||||||
- infra/supervisor/workflows.toml (schedule definitions — tomllib, stdlib)
|
- infra/supervisor/workflows.toml (schedule definitions — tomllib, stdlib)
|
||||||
|
- app.db tasks table (run_transform, run_export, run_pipeline task rows)
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
@@ -49,7 +52,7 @@ _LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
|
|||||||
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
||||||
|
|
||||||
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[5]
|
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||||
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
||||||
|
|
||||||
# A "running" row older than this is considered stale/crashed.
|
# A "running" row older than this is considered stale/crashed.
|
||||||
@@ -626,10 +629,8 @@ async def pipeline_dashboard():
|
|||||||
# ── Overview tab ─────────────────────────────────────────────────────────────
|
# ── Overview tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/overview")
|
async def _render_overview_partial():
|
||||||
@role_required("admin")
|
"""Build and render the pipeline overview partial (shared by GET and POST triggers)."""
|
||||||
async def pipeline_overview():
|
|
||||||
"""HTMX tab: extraction status per source, serving freshness, landing zone."""
|
|
||||||
latest_runs, landing_stats, workflows, serving_meta = await asyncio.gather(
|
latest_runs, landing_stats, workflows, serving_meta = await asyncio.gather(
|
||||||
asyncio.to_thread(_fetch_latest_per_extractor_sync),
|
asyncio.to_thread(_fetch_latest_per_extractor_sync),
|
||||||
asyncio.to_thread(_get_landing_zone_stats_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,
|
"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
|
# Compute landing zone totals
|
||||||
total_landing_bytes = sum(s["total_bytes"] for s in landing_stats)
|
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,
|
total_landing_bytes=total_landing_bytes,
|
||||||
serving_tables=serving_tables,
|
serving_tables=serving_tables,
|
||||||
last_export=last_export,
|
last_export=last_export,
|
||||||
|
any_running=any_running,
|
||||||
format_bytes=_format_bytes,
|
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 ────────────────────────────────────────────────────────────
|
# ── Extractions tab ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -745,7 +761,11 @@ async def pipeline_mark_stale(run_id: int):
|
|||||||
@role_required("admin")
|
@role_required("admin")
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def pipeline_trigger_extract():
|
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
|
from ..worker import enqueue
|
||||||
|
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -757,11 +777,15 @@ async def pipeline_trigger_extract():
|
|||||||
await flash(f"Unknown extractor '{extractor}'.", "warning")
|
await flash(f"Unknown extractor '{extractor}'.", "warning")
|
||||||
return redirect(url_for("pipeline.pipeline_dashboard"))
|
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||||
await enqueue("run_extraction", {"extractor": extractor})
|
await enqueue("run_extraction", {"extractor": extractor})
|
||||||
await flash(f"Extractor '{extractor}' queued. Check the task queue for progress.", "success")
|
|
||||||
else:
|
else:
|
||||||
await enqueue("run_extraction")
|
await enqueue("run_extraction")
|
||||||
await flash("Extraction run queued. Check the task queue for progress.", "success")
|
|
||||||
|
|
||||||
|
is_htmx = request.headers.get("HX-Request") == "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"))
|
return redirect(url_for("pipeline.pipeline_dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@@ -847,6 +871,156 @@ 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"
|
||||||
|
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"
|
||||||
|
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 ───────────────────────────────────────────────────────────────
|
# ── Catalog tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,6 @@ async def pseo_generate_gaps(slug: str):
|
|||||||
"template_slug": slug,
|
"template_slug": slug,
|
||||||
"start_date": date.today().isoformat(),
|
"start_date": date.today().isoformat(),
|
||||||
"articles_per_day": 500,
|
"articles_per_day": 500,
|
||||||
"limit": 500,
|
|
||||||
})
|
})
|
||||||
await flash(
|
await flash(
|
||||||
f"Queued generation for {len(gaps)} missing articles in '{config['name']}'.",
|
f"Queued generation for {len(gaps)} missing articles in '{config['name']}'.",
|
||||||
|
|||||||
@@ -1865,7 +1865,7 @@ async def template_preview(slug: str, row_key: str):
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def template_generate(slug: str):
|
async def template_generate(slug: str):
|
||||||
"""Generate articles from template + DuckDB data."""
|
"""Generate articles from template + DuckDB data."""
|
||||||
from ..content import fetch_template_data, load_template
|
from ..content import count_template_data, load_template
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = load_template(slug)
|
config = load_template(slug)
|
||||||
@@ -1873,8 +1873,7 @@ async def template_generate(slug: str):
|
|||||||
await flash("Template not found.", "error")
|
await flash("Template not found.", "error")
|
||||||
return redirect(url_for("admin.templates"))
|
return redirect(url_for("admin.templates"))
|
||||||
|
|
||||||
data_rows = await fetch_template_data(config["data_table"], limit=501)
|
row_count = await count_template_data(config["data_table"])
|
||||||
row_count = len(data_rows)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -1888,7 +1887,6 @@ async def template_generate(slug: str):
|
|||||||
"template_slug": slug,
|
"template_slug": slug,
|
||||||
"start_date": start_date.isoformat(),
|
"start_date": start_date.isoformat(),
|
||||||
"articles_per_day": articles_per_day,
|
"articles_per_day": articles_per_day,
|
||||||
"limit": 500,
|
|
||||||
})
|
})
|
||||||
await flash(
|
await flash(
|
||||||
f"Article generation queued for '{config['name']}'. "
|
f"Article generation queued for '{config['name']}'. "
|
||||||
@@ -1923,7 +1921,6 @@ async def template_regenerate(slug: str):
|
|||||||
"template_slug": slug,
|
"template_slug": slug,
|
||||||
"start_date": date.today().isoformat(),
|
"start_date": date.today().isoformat(),
|
||||||
"articles_per_day": 500,
|
"articles_per_day": 500,
|
||||||
"limit": 500,
|
|
||||||
})
|
})
|
||||||
await flash("Regeneration queued. The worker will process it in the background.", "success")
|
await flash("Regeneration queued. The worker will process it in the background.", "success")
|
||||||
return redirect(url_for("admin.template_detail", slug=slug))
|
return redirect(url_for("admin.template_detail", slug=slug))
|
||||||
@@ -2729,7 +2726,6 @@ async def rebuild_all():
|
|||||||
"template_slug": t["slug"],
|
"template_slug": t["slug"],
|
||||||
"start_date": date.today().isoformat(),
|
"start_date": date.today().isoformat(),
|
||||||
"articles_per_day": 500,
|
"articles_per_day": 500,
|
||||||
"limit": 500,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Manual articles still need inline rebuild
|
# Manual articles still need inline rebuild
|
||||||
@@ -2769,7 +2765,10 @@ async def _rebuild_article(article_id: int):
|
|||||||
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
md_path = Path("data/content/articles") / f"{article['slug']}.md"
|
||||||
if not md_path.exists():
|
if not md_path.exists():
|
||||||
return
|
return
|
||||||
body_html = mistune.html(md_path.read_text())
|
raw = md_path.read_text()
|
||||||
|
m = _FRONTMATTER_RE.match(raw)
|
||||||
|
body = raw[m.end():] if m else raw
|
||||||
|
body_html = mistune.html(body)
|
||||||
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
||||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||||
body_html = await bake_product_cards(body_html, lang=lang)
|
body_html = await bake_product_cards(body_html, lang=lang)
|
||||||
@@ -3034,6 +3033,7 @@ async def outreach():
|
|||||||
current_search=search,
|
current_search=search,
|
||||||
current_follow_up=follow_up,
|
current_follow_up=follow_up,
|
||||||
page=page,
|
page=page,
|
||||||
|
outreach_email=EMAIL_ADDRESSES["outreach"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3254,6 +3254,210 @@ async def outreach_import():
|
|||||||
|
|
||||||
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
||||||
AFFILIATE_STATUSES = ("draft", "active", "archived")
|
AFFILIATE_STATUSES = ("draft", "active", "archived")
|
||||||
|
AFFILIATE_PROGRAM_STATUSES = ("active", "inactive")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Affiliate Programs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _form_to_program(form) -> dict:
|
||||||
|
"""Parse affiliate program form values into a data dict."""
|
||||||
|
commission_str = form.get("commission_pct", "").strip()
|
||||||
|
commission_pct = 0.0
|
||||||
|
if commission_str:
|
||||||
|
try:
|
||||||
|
commission_pct = float(commission_str.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
commission_pct = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": form.get("name", "").strip(),
|
||||||
|
"slug": form.get("slug", "").strip(),
|
||||||
|
"url_template": form.get("url_template", "").strip(),
|
||||||
|
"tracking_tag": form.get("tracking_tag", "").strip(),
|
||||||
|
"commission_pct": commission_pct,
|
||||||
|
"homepage_url": form.get("homepage_url", "").strip(),
|
||||||
|
"status": form.get("status", "active").strip(),
|
||||||
|
"notes": form.get("notes", "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs")
|
||||||
|
@role_required("admin")
|
||||||
|
async def affiliate_programs():
|
||||||
|
"""Affiliate programs list — full page."""
|
||||||
|
from ..affiliate import get_all_programs
|
||||||
|
|
||||||
|
programs = await get_all_programs()
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_programs.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
programs=programs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/results")
|
||||||
|
@role_required("admin")
|
||||||
|
async def affiliate_program_results():
|
||||||
|
"""HTMX partial: program rows."""
|
||||||
|
from ..affiliate import get_all_programs
|
||||||
|
|
||||||
|
programs = await get_all_programs()
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/affiliate_program_results.html",
|
||||||
|
programs=programs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/new", methods=["GET", "POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def affiliate_program_new():
|
||||||
|
"""Create an affiliate program."""
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
data = _form_to_program(form)
|
||||||
|
|
||||||
|
if not data["name"] or not data["slug"] or not data["url_template"]:
|
||||||
|
await flash("Name, slug, and URL template are required.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data=data,
|
||||||
|
editing=False,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = await fetch_one(
|
||||||
|
"SELECT id FROM affiliate_programs WHERE slug = ?", (data["slug"],)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
await flash(f"Slug '{data['slug']}' already exists.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data=data,
|
||||||
|
editing=False,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO affiliate_programs
|
||||||
|
(name, slug, url_template, tracking_tag, commission_pct,
|
||||||
|
homepage_url, status, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
data["name"], data["slug"], data["url_template"],
|
||||||
|
data["tracking_tag"], data["commission_pct"],
|
||||||
|
data["homepage_url"], data["status"], data["notes"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flash(f"Program '{data['name']}' created.", "success")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data={},
|
||||||
|
editing=False,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/<int:program_id>/edit", methods=["GET", "POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def affiliate_program_edit(program_id: int):
|
||||||
|
"""Edit an affiliate program."""
|
||||||
|
program = await fetch_one(
|
||||||
|
"SELECT * FROM affiliate_programs WHERE id = ?", (program_id,)
|
||||||
|
)
|
||||||
|
if not program:
|
||||||
|
await flash("Program not found.", "error")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
data = _form_to_program(form)
|
||||||
|
|
||||||
|
if not data["name"] or not data["slug"] or not data["url_template"]:
|
||||||
|
await flash("Name, slug, and URL template are required.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data={**dict(program), **data},
|
||||||
|
editing=True,
|
||||||
|
program_id=program_id,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data["slug"] != program["slug"]:
|
||||||
|
collision = await fetch_one(
|
||||||
|
"SELECT id FROM affiliate_programs WHERE slug = ? AND id != ?",
|
||||||
|
(data["slug"], program_id),
|
||||||
|
)
|
||||||
|
if collision:
|
||||||
|
await flash(f"Slug '{data['slug']}' already exists.", "error")
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data={**dict(program), **data},
|
||||||
|
editing=True,
|
||||||
|
program_id=program_id,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"""UPDATE affiliate_programs
|
||||||
|
SET name=?, slug=?, url_template=?, tracking_tag=?, commission_pct=?,
|
||||||
|
homepage_url=?, status=?, notes=?, updated_at=datetime('now')
|
||||||
|
WHERE id=?""",
|
||||||
|
(
|
||||||
|
data["name"], data["slug"], data["url_template"],
|
||||||
|
data["tracking_tag"], data["commission_pct"],
|
||||||
|
data["homepage_url"], data["status"], data["notes"],
|
||||||
|
program_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flash(f"Program '{data['name']}' updated.", "success")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/affiliate_program_form.html",
|
||||||
|
admin_page="affiliate_programs",
|
||||||
|
data=dict(program),
|
||||||
|
editing=True,
|
||||||
|
program_id=program_id,
|
||||||
|
program_statuses=AFFILIATE_PROGRAM_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/affiliate/programs/<int:program_id>/delete", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def affiliate_program_delete(program_id: int):
|
||||||
|
"""Delete an affiliate program — blocked if products reference it."""
|
||||||
|
program = await fetch_one(
|
||||||
|
"SELECT name FROM affiliate_programs WHERE id = ?", (program_id,)
|
||||||
|
)
|
||||||
|
if not program:
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
product_count = await fetch_one(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM affiliate_products WHERE program_id = ?",
|
||||||
|
(program_id,),
|
||||||
|
)
|
||||||
|
count = product_count["cnt"] if product_count else 0
|
||||||
|
if count > 0:
|
||||||
|
await flash(
|
||||||
|
f"Cannot delete '{program['name']}' — {count} product(s) reference it. "
|
||||||
|
"Reassign or remove those products first.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
await execute("DELETE FROM affiliate_programs WHERE id = ?", (program_id,))
|
||||||
|
await flash(f"Program '{program['name']}' deleted.", "success")
|
||||||
|
return redirect(url_for("admin.affiliate_programs"))
|
||||||
|
|
||||||
|
|
||||||
def _form_to_product(form) -> dict:
|
def _form_to_product(form) -> dict:
|
||||||
@@ -3279,13 +3483,26 @@ def _form_to_product(form) -> dict:
|
|||||||
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
|
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
|
||||||
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
|
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
|
||||||
|
|
||||||
|
# Program-based URL vs manual URL.
|
||||||
|
# When a program is selected, product_identifier holds the ASIN/path;
|
||||||
|
# affiliate_url is cleared. Manual mode is the reverse.
|
||||||
|
program_id_str = form.get("program_id", "").strip()
|
||||||
|
program_id = int(program_id_str) if program_id_str and program_id_str != "0" else None
|
||||||
|
product_identifier = form.get("product_identifier", "").strip()
|
||||||
|
affiliate_url = form.get("affiliate_url", "").strip()
|
||||||
|
|
||||||
|
# retailer is auto-populated from program name on save (kept for display/filter)
|
||||||
|
retailer = form.get("retailer", "").strip()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"slug": form.get("slug", "").strip(),
|
"slug": form.get("slug", "").strip(),
|
||||||
"name": form.get("name", "").strip(),
|
"name": form.get("name", "").strip(),
|
||||||
"brand": form.get("brand", "").strip(),
|
"brand": form.get("brand", "").strip(),
|
||||||
"category": form.get("category", "accessory").strip(),
|
"category": form.get("category", "accessory").strip(),
|
||||||
"retailer": form.get("retailer", "").strip(),
|
"retailer": retailer,
|
||||||
"affiliate_url": form.get("affiliate_url", "").strip(),
|
"program_id": program_id,
|
||||||
|
"product_identifier": product_identifier,
|
||||||
|
"affiliate_url": affiliate_url,
|
||||||
"image_url": form.get("image_url", "").strip(),
|
"image_url": form.get("image_url", "").strip(),
|
||||||
"price_cents": price_cents,
|
"price_cents": price_cents,
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
@@ -3403,14 +3620,15 @@ async def affiliate_preview():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def affiliate_new():
|
async def affiliate_new():
|
||||||
"""Create an affiliate product."""
|
"""Create an affiliate product."""
|
||||||
from ..affiliate import get_distinct_retailers
|
from ..affiliate import get_all_programs, get_distinct_retailers
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = await request.form
|
form = await request.form
|
||||||
data = _form_to_product(form)
|
data = _form_to_product(form)
|
||||||
|
|
||||||
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
|
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
|
||||||
await flash("Slug, name, and affiliate URL are required.", "error")
|
if not data["slug"] or not data["name"] or not has_url:
|
||||||
|
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/affiliate_form.html",
|
"admin/affiliate_form.html",
|
||||||
admin_page="affiliate",
|
admin_page="affiliate",
|
||||||
@@ -3419,6 +3637,7 @@ async def affiliate_new():
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
existing = await fetch_one(
|
existing = await fetch_one(
|
||||||
@@ -3435,17 +3654,27 @@ async def affiliate_new():
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-populate retailer from program name if not manually set
|
||||||
|
if data["program_id"] and not data["retailer"]:
|
||||||
|
prog = await fetch_one(
|
||||||
|
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
|
||||||
|
)
|
||||||
|
if prog:
|
||||||
|
data["retailer"] = prog["name"]
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""INSERT INTO affiliate_products
|
"""INSERT INTO affiliate_products
|
||||||
(slug, name, brand, category, retailer, affiliate_url, image_url,
|
(slug, name, brand, category, retailer, program_id, product_identifier,
|
||||||
price_cents, currency, rating, pros, cons, description, cta_label,
|
affiliate_url, image_url, price_cents, currency, rating, pros, cons,
|
||||||
status, language, sort_order)
|
description, cta_label, status, language, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
data["slug"], data["name"], data["brand"], data["category"],
|
data["slug"], data["name"], data["brand"], data["category"],
|
||||||
data["retailer"], data["affiliate_url"], data["image_url"],
|
data["retailer"], data["program_id"], data["product_identifier"],
|
||||||
|
data["affiliate_url"], data["image_url"],
|
||||||
data["price_cents"], data["currency"], data["rating"],
|
data["price_cents"], data["currency"], data["rating"],
|
||||||
data["pros"], data["cons"], data["description"], data["cta_label"],
|
data["pros"], data["cons"], data["description"], data["cta_label"],
|
||||||
data["status"], data["language"], data["sort_order"],
|
data["status"], data["language"], data["sort_order"],
|
||||||
@@ -3462,6 +3691,7 @@ async def affiliate_new():
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3470,7 +3700,7 @@ async def affiliate_new():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def affiliate_edit(product_id: int):
|
async def affiliate_edit(product_id: int):
|
||||||
"""Edit an affiliate product."""
|
"""Edit an affiliate product."""
|
||||||
from ..affiliate import get_distinct_retailers
|
from ..affiliate import get_all_programs, get_distinct_retailers
|
||||||
|
|
||||||
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
|
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
|
||||||
if not product:
|
if not product:
|
||||||
@@ -3481,8 +3711,9 @@ async def affiliate_edit(product_id: int):
|
|||||||
form = await request.form
|
form = await request.form
|
||||||
data = _form_to_product(form)
|
data = _form_to_product(form)
|
||||||
|
|
||||||
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
|
has_url = bool(data["program_id"] and data["product_identifier"]) or bool(data["affiliate_url"])
|
||||||
await flash("Slug, name, and affiliate URL are required.", "error")
|
if not data["slug"] or not data["name"] or not has_url:
|
||||||
|
await flash("Slug, name, and either a program+product ID or manual URL are required.", "error")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"admin/affiliate_form.html",
|
"admin/affiliate_form.html",
|
||||||
admin_page="affiliate",
|
admin_page="affiliate",
|
||||||
@@ -3492,6 +3723,7 @@ async def affiliate_edit(product_id: int):
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check slug collision only if slug or language changed
|
# Check slug collision only if slug or language changed
|
||||||
@@ -3511,18 +3743,29 @@ async def affiliate_edit(product_id: int):
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-populate retailer from program name if not manually set
|
||||||
|
if data["program_id"] and not data["retailer"]:
|
||||||
|
prog = await fetch_one(
|
||||||
|
"SELECT name FROM affiliate_programs WHERE id = ?", (data["program_id"],)
|
||||||
|
)
|
||||||
|
if prog:
|
||||||
|
data["retailer"] = prog["name"]
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""UPDATE affiliate_products
|
"""UPDATE affiliate_products
|
||||||
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?,
|
SET slug=?, name=?, brand=?, category=?, retailer=?, program_id=?,
|
||||||
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?,
|
product_identifier=?, affiliate_url=?, image_url=?,
|
||||||
|
price_cents=?, currency=?, rating=?, pros=?, cons=?,
|
||||||
description=?, cta_label=?, status=?, language=?, sort_order=?,
|
description=?, cta_label=?, status=?, language=?, sort_order=?,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
WHERE id=?""",
|
WHERE id=?""",
|
||||||
(
|
(
|
||||||
data["slug"], data["name"], data["brand"], data["category"],
|
data["slug"], data["name"], data["brand"], data["category"],
|
||||||
data["retailer"], data["affiliate_url"], data["image_url"],
|
data["retailer"], data["program_id"], data["product_identifier"],
|
||||||
|
data["affiliate_url"], data["image_url"],
|
||||||
data["price_cents"], data["currency"], data["rating"],
|
data["price_cents"], data["currency"], data["rating"],
|
||||||
data["pros"], data["cons"], data["description"], data["cta_label"],
|
data["pros"], data["cons"], data["description"], data["cta_label"],
|
||||||
data["status"], data["language"], data["sort_order"],
|
data["status"], data["language"], data["sort_order"],
|
||||||
@@ -3554,6 +3797,7 @@ async def affiliate_edit(product_id: int):
|
|||||||
categories=AFFILIATE_CATEGORIES,
|
categories=AFFILIATE_CATEGORIES,
|
||||||
statuses=AFFILIATE_STATUSES,
|
statuses=AFFILIATE_STATUSES,
|
||||||
retailers=await get_distinct_retailers(),
|
retailers=await get_distinct_retailers(),
|
||||||
|
programs=await get_all_programs(status="active"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
slugInput.dataset.manual = '1';
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -87,9 +101,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Retailer #}
|
{# Program dropdown #}
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="f-retailer">Retailer</label>
|
<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','') }}"
|
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
|
||||||
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
||||||
list="retailers-list">
|
list="retailers-list">
|
||||||
@@ -100,14 +143,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Affiliate URL #}
|
|
||||||
<div>
|
|
||||||
<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" required>
|
|
||||||
<p class="form-hint">Full URL with tracking params already baked in.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Image URL #}
|
{# Image URL #}
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="f-image">Image URL</label>
|
<label class="form-label" for="f-image">Image URL</label>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% 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">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline"
|
||||||
|
onclick="return confirm('Delete this program? Blocked if products reference it.')">Delete</button>
|
||||||
|
</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 %}
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
.admin-subnav {
|
.admin-subnav {
|
||||||
display: flex; align-items: stretch; padding: 0 2rem;
|
display: flex; align-items: stretch; padding: 0 2rem;
|
||||||
background: #fff; border-bottom: 1px solid #E2E8F0;
|
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 {
|
.admin-subnav a {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
padding: 0 1px; margin: 0 13px 0 0; height: 42px;
|
padding: 0 1px; margin: 0 13px 0 0; height: 42px;
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
'suppliers': 'suppliers',
|
'suppliers': 'suppliers',
|
||||||
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
||||||
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
||||||
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate',
|
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate', 'affiliate_programs': 'affiliate',
|
||||||
'billing': 'billing',
|
'billing': 'billing',
|
||||||
'seo': 'analytics',
|
'seo': 'analytics',
|
||||||
'pipeline': 'pipeline',
|
'pipeline': 'pipeline',
|
||||||
@@ -206,6 +208,7 @@
|
|||||||
<nav class="admin-subnav">
|
<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_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_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>
|
</nav>
|
||||||
{% elif active_section == 'system' %}
|
{% elif active_section == 'system' %}
|
||||||
<nav class="admin-subnav">
|
<nav class="admin-subnav">
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
|
|
||||||
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.funnel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.funnel-grid { grid-template-columns: repeat(5, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-8">
|
<header class="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -47,7 +60,7 @@
|
|||||||
|
|
||||||
<!-- Lead Funnel -->
|
<!-- Lead Funnel -->
|
||||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
|
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
|
||||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
<div class="funnel-grid mb-8">
|
||||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||||
<p class="text-xs text-slate">Planner Users</p>
|
<p class="text-xs text-slate">Planner Users</p>
|
||||||
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
||||||
@@ -72,7 +85,7 @@
|
|||||||
|
|
||||||
<!-- Supplier Stats -->
|
<!-- Supplier Stats -->
|
||||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
|
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
|
||||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
<div class="funnel-grid mb-8">
|
||||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||||
<p class="text-xs text-slate">Claimed Suppliers</p>
|
<p class="text-xs text-slate">Claimed Suppliers</p>
|
||||||
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
||||||
|
|||||||
@@ -2,13 +2,30 @@
|
|||||||
{% set admin_page = "outreach" %}
|
{% set admin_page = "outreach" %}
|
||||||
{% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.pipeline-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.pipeline-status-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.pipeline-status-grid { grid-template-columns: repeat(6, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-6">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Outreach</h1>
|
<h1 class="text-2xl">Outreach</h1>
|
||||||
<p class="text-sm text-slate mt-1">
|
<p class="text-sm text-slate mt-1">
|
||||||
{{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline
|
{{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline
|
||||||
· Sending domain: <span class="mono text-xs">hello.padelnomics.io</span>
|
· Sending from: <span class="mono text-xs">{{ outreach_email }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -18,7 +35,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Pipeline cards -->
|
<!-- Pipeline cards -->
|
||||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem;margin-bottom:1.5rem">
|
<div class="pipeline-status-grid">
|
||||||
{% set status_colors = {
|
{% set status_colors = {
|
||||||
'prospect': '#E2E8F0',
|
'prospect': '#E2E8F0',
|
||||||
'contacted': '#DBEAFE',
|
'contacted': '#DBEAFE',
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% if programs %}
|
||||||
|
{% for prog in programs %}
|
||||||
|
<tr id="prog-{{ prog.id }}">
|
||||||
|
<td style="font-weight:500">
|
||||||
|
{% if prog.homepage_url %}
|
||||||
|
<a href="{{ prog.homepage_url }}" target="_blank" rel="noopener" style="color:#0F172A;text-decoration:none">{{ prog.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ prog.name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-slate">{{ prog.slug }}</td>
|
||||||
|
<td class="mono text-slate">{{ prog.tracking_tag or '—' }}</td>
|
||||||
|
<td class="mono text-right">
|
||||||
|
{% if prog.commission_pct %}{{ "%.0f" | format(prog.commission_pct) }}%{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-right">{{ prog.product_count }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if prog.status == 'active' %}badge-success{% else %}badge{% endif %}">
|
||||||
|
{{ prog.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right" style="white-space:nowrap">
|
||||||
|
<a href="{{ url_for('admin.affiliate_program_edit', program_id=prog.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('admin.affiliate_program_delete', program_id=prog.id) }}" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn-outline btn-sm"
|
||||||
|
onclick="return confirm('Delete {{ prog.name }}? This is blocked if products reference it.')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-slate" style="text-align:center;padding:2rem;">No programs found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if emails %}
|
{% if emails %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
{% if leads %}
|
{% if leads %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if suppliers %}
|
{% if suppliers %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
<!-- Pipeline Overview Tab: extraction status, serving freshness, landing zone -->
|
<!-- Pipeline Overview Tab: extraction status, serving freshness, landing zone
|
||||||
|
Self-polls every 5s while any extraction task is pending, stops when quiet. -->
|
||||||
|
|
||||||
|
<div id="pipeline-overview-content"
|
||||||
|
hx-get="{{ url_for('pipeline.pipeline_overview') }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% if any_running %}hx-trigger="every 5s"{% endif %}>
|
||||||
|
|
||||||
<!-- Extraction Status Grid -->
|
<!-- Extraction Status Grid -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
@@ -26,12 +33,14 @@
|
|||||||
{% if stale %}
|
{% if stale %}
|
||||||
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
|
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0 ml-auto">
|
<button type="button"
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
class="btn btn-sm ml-auto"
|
||||||
<input type="hidden" name="extractor" value="{{ wf.name }}">
|
style="padding:2px 8px;font-size:11px"
|
||||||
<button type="button" class="btn btn-sm" style="padding:2px 8px;font-size:11px"
|
hx-post="{{ url_for('pipeline.pipeline_trigger_extract') }}"
|
||||||
onclick="confirmAction('Run {{ wf.name }} extractor?', this.closest('form'))">Run</button>
|
hx-target="#pipeline-overview-content"
|
||||||
</form>
|
hx-swap="outerHTML"
|
||||||
|
hx-vals='{"extractor": "{{ wf.name }}", "csrf_token": "{{ csrf_token() }}"}'
|
||||||
|
onclick="if (!confirm('Run {{ wf.name }} extractor?')) return false;">Run</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
|
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
|
||||||
{% if run %}
|
{% if run %}
|
||||||
@@ -57,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Two-column row: Serving Freshness + Landing Zone -->
|
<!-- Two-column row: Serving Freshness + Landing Zone -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
<div class="pipeline-two-col">
|
||||||
|
|
||||||
<!-- Serving Freshness -->
|
<!-- Serving Freshness -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -68,6 +77,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if serving_tables %}
|
{% if serving_tables %}
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table" style="font-size:0.8125rem">
|
<table class="table" style="font-size:0.8125rem">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -86,6 +96,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-slate">No serving tables found — run the pipeline first.</p>
|
<p class="text-sm text-slate">No serving tables found — run the pipeline first.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,6 +110,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{% if landing_stats %}
|
{% if landing_stats %}
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table" style="font-size:0.8125rem">
|
<table class="table" style="font-size:0.8125rem">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -119,6 +131,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-slate">
|
<p class="text-sm text-slate">
|
||||||
Landing zone empty or not found at <code>data/landing</code>.
|
Landing zone empty or not found at <code>data/landing</code>.
|
||||||
@@ -127,3 +140,5 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>{# end #pipeline-overview-content #}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!-- Pipeline Transform Tab: SQLMesh + export status, run history
|
||||||
|
Self-polls every 5s while any transform/export task is pending. -->
|
||||||
|
|
||||||
|
<div id="pipeline-transform-content"
|
||||||
|
hx-get="{{ url_for('pipeline.pipeline_transform') }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% if any_running %}hx-trigger="every 5s"{% endif %}>
|
||||||
|
|
||||||
|
<!-- Status Cards: Transform + Export -->
|
||||||
|
<div class="pipeline-two-col mb-4">
|
||||||
|
|
||||||
|
<!-- SQLMesh Transform -->
|
||||||
|
{% set tx = latest['run_transform'] %}
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-header">SQLMesh Transform</p>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
{% if tx is none %}
|
||||||
|
<span class="status-dot pending"></span>
|
||||||
|
<span class="text-sm text-slate">Never run</span>
|
||||||
|
{% elif tx.status == 'pending' %}
|
||||||
|
<span class="status-dot running"></span>
|
||||||
|
<span class="text-sm text-slate">Running…</span>
|
||||||
|
{% elif tx.status == 'complete' %}
|
||||||
|
<span class="status-dot ok"></span>
|
||||||
|
<span class="text-sm text-slate">Complete</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-dot failed"></span>
|
||||||
|
<span class="text-sm text-danger">Failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if tx %}
|
||||||
|
<p class="text-xs text-slate mono">
|
||||||
|
Started: {{ (tx.created_at or '')[:19] or '—' }}
|
||||||
|
</p>
|
||||||
|
{% if tx.completed_at %}
|
||||||
|
<p class="text-xs text-slate mono">
|
||||||
|
Finished: {{ tx.completed_at[:19] }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if tx.status == 'failed' and tx.error %}
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="text-xs text-danger cursor-pointer">Error</summary>
|
||||||
|
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto" style="max-height:8rem;white-space:pre-wrap">{{ tx.error[:400] }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
{% if any_running %}disabled{% endif %}
|
||||||
|
hx-post="{{ url_for('pipeline.pipeline_trigger_transform') }}"
|
||||||
|
hx-target="#pipeline-transform-content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals='{"step": "transform", "csrf_token": "{{ csrf_token() }}"}'
|
||||||
|
onclick="if (!confirm('Run SQLMesh transform (prod --auto-apply)?')) return false;">
|
||||||
|
Run Transform
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Serving -->
|
||||||
|
{% set ex = latest['run_export'] %}
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-header">Export Serving</p>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
{% if ex is none %}
|
||||||
|
<span class="status-dot pending"></span>
|
||||||
|
<span class="text-sm text-slate">Never run</span>
|
||||||
|
{% elif ex.status == 'pending' %}
|
||||||
|
<span class="status-dot running"></span>
|
||||||
|
<span class="text-sm text-slate">Running…</span>
|
||||||
|
{% elif ex.status == 'complete' %}
|
||||||
|
<span class="status-dot ok"></span>
|
||||||
|
<span class="text-sm text-slate">Complete</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-dot failed"></span>
|
||||||
|
<span class="text-sm text-danger">Failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if ex %}
|
||||||
|
<p class="text-xs text-slate mono">
|
||||||
|
Started: {{ (ex.created_at or '')[:19] or '—' }}
|
||||||
|
</p>
|
||||||
|
{% if ex.completed_at %}
|
||||||
|
<p class="text-xs text-slate mono">
|
||||||
|
Finished: {{ ex.completed_at[:19] }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if serving_meta %}
|
||||||
|
<p class="text-xs text-slate mt-1">
|
||||||
|
Last export: <span class="font-semibold mono">{{ (serving_meta.exported_at_utc or '')[:19].replace('T', ' ') or '—' }}</span>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if ex.status == 'failed' and ex.error %}
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="text-xs text-danger cursor-pointer">Error</summary>
|
||||||
|
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto" style="max-height:8rem;white-space:pre-wrap">{{ ex.error[:400] }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
{% if any_running %}disabled{% endif %}
|
||||||
|
hx-post="{{ url_for('pipeline.pipeline_trigger_transform') }}"
|
||||||
|
hx-target="#pipeline-transform-content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals='{"step": "export", "csrf_token": "{{ csrf_token() }}"}'
|
||||||
|
onclick="if (!confirm('Export serving tables (lakehouse → analytics.duckdb)?')) return false;">
|
||||||
|
Run Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Run Full Pipeline -->
|
||||||
|
{% set pl = latest['run_pipeline'] %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-navy text-sm">Full Pipeline</p>
|
||||||
|
<p class="text-xs text-slate mt-1">Runs extract → transform → export sequentially</p>
|
||||||
|
{% if pl %}
|
||||||
|
<p class="text-xs text-slate mono mt-1">
|
||||||
|
Last: {{ (pl.created_at or '')[:19] or '—' }}
|
||||||
|
{% if pl.status == 'complete' %}<span class="badge-success ml-2">Complete</span>{% endif %}
|
||||||
|
{% if pl.status == 'pending' %}<span class="badge-warning ml-2">Running…</span>{% endif %}
|
||||||
|
{% if pl.status == 'failed' %}<span class="badge-danger ml-2">Failed</span>{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
{% if any_running %}disabled{% endif %}
|
||||||
|
hx-post="{{ url_for('pipeline.pipeline_trigger_transform') }}"
|
||||||
|
hx-target="#pipeline-transform-content"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals='{"step": "pipeline", "csrf_token": "{{ csrf_token() }}"}'
|
||||||
|
onclick="if (!confirm('Run full ELT pipeline (extract → transform → export)?')) return false;">
|
||||||
|
Run Full Pipeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Runs -->
|
||||||
|
<div class="card">
|
||||||
|
<p class="card-header">Recent Runs</p>
|
||||||
|
{% if history %}
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="table" style="font-size:0.8125rem">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Step</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in history %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-xs text-slate">{{ row.id }}</td>
|
||||||
|
<td class="mono text-xs">{{ row.task_name | replace('run_', '') }}</td>
|
||||||
|
<td class="mono text-xs text-slate">{{ (row.created_at or '')[:19] or '—' }}</td>
|
||||||
|
<td class="mono text-xs text-slate">{{ row.duration or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.status == 'complete' %}
|
||||||
|
<span class="badge-success">Complete</span>
|
||||||
|
{% elif row.status == 'failed' %}
|
||||||
|
<span class="badge-danger">Failed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-warning">Running…</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if row.error_short %}
|
||||||
|
<details>
|
||||||
|
<summary class="text-xs text-danger cursor-pointer">Error</summary>
|
||||||
|
<pre class="text-xs mt-1 p-2 bg-gray-50 rounded overflow-auto" style="max-width:24rem;white-space:pre-wrap">{{ row.error_short }}</pre>
|
||||||
|
</details>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-slate">No transform runs yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{# end #pipeline-transform-content #}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{% if suppliers %}
|
{% if suppliers %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<div style="overflow-x:auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
|
|||||||
@@ -4,8 +4,18 @@
|
|||||||
|
|
||||||
{% block admin_head %}
|
{% block admin_head %}
|
||||||
<style>
|
<style>
|
||||||
|
.pipeline-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.pipeline-stat-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
.pipeline-tabs {
|
.pipeline-tabs {
|
||||||
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
|
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
|
||||||
|
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
.pipeline-tabs button {
|
.pipeline-tabs button {
|
||||||
padding: 0.625rem 1.25rem; font-size: 0.8125rem; font-weight: 600;
|
padding: 0.625rem 1.25rem; font-size: 0.8125rem; font-weight: 600;
|
||||||
@@ -23,7 +33,19 @@
|
|||||||
.status-dot.failed { background: #EF4444; }
|
.status-dot.failed { background: #EF4444; }
|
||||||
.status-dot.stale { background: #D97706; }
|
.status-dot.stale { background: #D97706; }
|
||||||
.status-dot.running { background: #3B82F6; }
|
.status-dot.running { background: #3B82F6; }
|
||||||
|
|
||||||
|
@keyframes pulse-dot { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||||
|
.status-dot.running { animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||||
.status-dot.pending { background: #CBD5E1; }
|
.status-dot.pending { background: #CBD5E1; }
|
||||||
|
|
||||||
|
.pipeline-two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.pipeline-two-col { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -34,10 +56,11 @@
|
|||||||
<p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p>
|
<p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0">
|
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_transform') }}" class="m-0">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="step" value="pipeline">
|
||||||
<button type="button" class="btn btn-sm"
|
<button type="button" class="btn btn-sm"
|
||||||
onclick="confirmAction('Enqueue a full extraction run? This will run all extractors in the background.', this.closest('form'))">
|
onclick="confirmAction('Run full ELT pipeline (extract → transform → export)? This runs in the background.', this.closest('form'))">
|
||||||
Run Pipeline
|
Run Pipeline
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -46,7 +69,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Health stat cards -->
|
<!-- Health stat cards -->
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem" class="mb-6">
|
<div class="pipeline-stat-grid mb-6">
|
||||||
<div class="card text-center" style="padding:0.875rem">
|
<div class="card text-center" style="padding:0.875rem">
|
||||||
<p class="text-xs text-slate">Total Runs</p>
|
<p class="text-xs text-slate">Total Runs</p>
|
||||||
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>
|
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>
|
||||||
@@ -97,6 +120,10 @@
|
|||||||
hx-get="{{ url_for('pipeline.pipeline_lineage') }}"
|
hx-get="{{ url_for('pipeline.pipeline_lineage') }}"
|
||||||
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
|
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
|
||||||
hx-trigger="click">Lineage</button>
|
hx-trigger="click">Lineage</button>
|
||||||
|
<button data-tab="transform"
|
||||||
|
hx-get="{{ url_for('pipeline.pipeline_transform') }}"
|
||||||
|
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
|
||||||
|
hx-trigger="click">Transform</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab content (Overview loads on page load) -->
|
<!-- Tab content (Overview loads on page load) -->
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
||||||
VALID_STATUSES = ("draft", "active", "archived")
|
VALID_STATUSES = ("draft", "active", "archived")
|
||||||
|
VALID_PROGRAM_STATUSES = ("active", "inactive")
|
||||||
|
|
||||||
|
|
||||||
def hash_ip(ip_address: str) -> str:
|
def hash_ip(ip_address: str) -> str:
|
||||||
@@ -32,20 +33,86 @@ def hash_ip(ip_address: str) -> str:
|
|||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def get_product(slug: str, language: str = "de") -> dict | None:
|
async def get_all_programs(status: str | None = None) -> list[dict]:
|
||||||
"""Return active product by slug+language, falling back to any language."""
|
"""Return all affiliate programs, optionally filtered by status."""
|
||||||
|
if status:
|
||||||
|
assert status in VALID_PROGRAM_STATUSES, f"unknown program status: {status}"
|
||||||
|
rows = await fetch_all(
|
||||||
|
"SELECT ap.*, ("
|
||||||
|
" SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id"
|
||||||
|
") AS product_count"
|
||||||
|
" FROM affiliate_programs ap WHERE ap.status = ?"
|
||||||
|
" ORDER BY ap.name ASC",
|
||||||
|
(status,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await fetch_all(
|
||||||
|
"SELECT ap.*, ("
|
||||||
|
" SELECT COUNT(*) FROM affiliate_products WHERE program_id = ap.id"
|
||||||
|
") AS product_count"
|
||||||
|
" FROM affiliate_programs ap ORDER BY ap.name ASC"
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_program(program_id: int) -> dict | None:
|
||||||
|
"""Return a single affiliate program by id."""
|
||||||
|
assert program_id > 0, "program_id must be positive"
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT * FROM affiliate_programs WHERE id = ?", (program_id,)
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_program_by_slug(slug: str) -> dict | None:
|
||||||
|
"""Return a single affiliate program by slug."""
|
||||||
assert slug, "slug must not be empty"
|
assert slug, "slug must not be empty"
|
||||||
row = await fetch_one(
|
row = await fetch_one(
|
||||||
"SELECT * FROM affiliate_products"
|
"SELECT * FROM affiliate_programs WHERE slug = ?", (slug,)
|
||||||
" WHERE slug = ? AND language = ? AND status = 'active'",
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_affiliate_url(product: dict, program: dict | None = None) -> str:
|
||||||
|
"""Assemble the final affiliate URL from program template + product identifier.
|
||||||
|
|
||||||
|
Falls back to the baked product["affiliate_url"] when no program is set,
|
||||||
|
preserving backward compatibility with products created before programs existed.
|
||||||
|
"""
|
||||||
|
if not product.get("program_id") or not program:
|
||||||
|
return product["affiliate_url"]
|
||||||
|
return program["url_template"].format(
|
||||||
|
product_id=product["product_identifier"],
|
||||||
|
tag=program["tracking_tag"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_product(slug: str, language: str = "de") -> dict | None:
|
||||||
|
"""Return active product by slug+language, falling back to any language.
|
||||||
|
|
||||||
|
JOINs affiliate_programs so the returned dict includes program fields
|
||||||
|
(prefixed with _program_*) for use in build_affiliate_url().
|
||||||
|
"""
|
||||||
|
assert slug, "slug must not be empty"
|
||||||
|
row = await fetch_one(
|
||||||
|
"SELECT p.*, pg.url_template AS _program_url_template,"
|
||||||
|
" pg.tracking_tag AS _program_tracking_tag,"
|
||||||
|
" pg.name AS _program_name"
|
||||||
|
" FROM affiliate_products p"
|
||||||
|
" LEFT JOIN affiliate_programs pg ON pg.id = p.program_id"
|
||||||
|
" WHERE p.slug = ? AND p.language = ? AND p.status = 'active'",
|
||||||
(slug, language),
|
(slug, language),
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
return _parse_product(row)
|
return _parse_product(row)
|
||||||
# Graceful fallback: show any language rather than nothing
|
# Graceful fallback: show any language rather than nothing
|
||||||
row = await fetch_one(
|
row = await fetch_one(
|
||||||
"SELECT * FROM affiliate_products"
|
"SELECT p.*, pg.url_template AS _program_url_template,"
|
||||||
" WHERE slug = ? AND status = 'active' LIMIT 1",
|
" pg.tracking_tag AS _program_tracking_tag,"
|
||||||
|
" pg.name AS _program_name"
|
||||||
|
" FROM affiliate_products p"
|
||||||
|
" LEFT JOIN affiliate_programs pg ON pg.id = p.program_id"
|
||||||
|
" WHERE p.slug = ? AND p.status = 'active' LIMIT 1",
|
||||||
(slug,),
|
(slug,),
|
||||||
)
|
)
|
||||||
return _parse_product(row) if row else None
|
return _parse_product(row) if row else None
|
||||||
@@ -217,8 +284,24 @@ async def get_distinct_retailers() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_product(row) -> dict:
|
def _parse_product(row) -> dict:
|
||||||
"""Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays."""
|
"""Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays.
|
||||||
|
|
||||||
|
If the row includes _program_* columns (from a JOIN), extracts them into
|
||||||
|
a nested "_program" dict so build_affiliate_url() can use them directly.
|
||||||
|
"""
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
d["pros"] = json.loads(d.get("pros") or "[]")
|
d["pros"] = json.loads(d.get("pros") or "[]")
|
||||||
d["cons"] = json.loads(d.get("cons") or "[]")
|
d["cons"] = json.loads(d.get("cons") or "[]")
|
||||||
|
# Extract program fields added by get_product()'s JOIN
|
||||||
|
if "_program_url_template" in d:
|
||||||
|
if d.get("program_id") and d["_program_url_template"]:
|
||||||
|
d["_program"] = {
|
||||||
|
"url_template": d.pop("_program_url_template"),
|
||||||
|
"tracking_tag": d.pop("_program_tracking_tag", ""),
|
||||||
|
"name": d.pop("_program_name", ""),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
d.pop("_program_url_template", None)
|
||||||
|
d.pop("_program_tracking_tag", None)
|
||||||
|
d.pop("_program_name", None)
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ def create_app() -> Quart:
|
|||||||
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
|
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
|
||||||
Extracts article_slug and lang from Referer header best-effort.
|
Extracts article_slug and lang from Referer header best-effort.
|
||||||
"""
|
"""
|
||||||
from .affiliate import get_product, log_click
|
from .affiliate import build_affiliate_url, get_product, log_click
|
||||||
from .core import check_rate_limit
|
from .core import check_rate_limit
|
||||||
|
|
||||||
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
|
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
|
||||||
@@ -314,14 +314,17 @@ def create_app() -> Quart:
|
|||||||
if not product:
|
if not product:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
# Assemble URL from program template; falls back to baked affiliate_url
|
||||||
|
url = build_affiliate_url(product, product.get("_program"))
|
||||||
|
|
||||||
ip = request.remote_addr or "unknown"
|
ip = request.remote_addr or "unknown"
|
||||||
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
|
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
# Still redirect even if rate-limited; just don't log the click
|
# Still redirect even if rate-limited; just don't log the click
|
||||||
return redirect(product["affiliate_url"], 302)
|
return redirect(url, 302)
|
||||||
|
|
||||||
await log_click(product["id"], ip, article_slug, referer or None)
|
await log_click(product["id"], ip, article_slug, referer or None)
|
||||||
return redirect(product["affiliate_url"], 302)
|
return redirect(url, 302)
|
||||||
|
|
||||||
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
||||||
@app.route("/terms")
|
@app.route("/terms")
|
||||||
|
|||||||
@@ -123,17 +123,19 @@ async def get_table_columns(data_table: str) -> list[dict]:
|
|||||||
async def fetch_template_data(
|
async def fetch_template_data(
|
||||||
data_table: str,
|
data_table: str,
|
||||||
order_by: str | None = None,
|
order_by: str | None = None,
|
||||||
limit: int = 500,
|
limit: int = 0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Fetch all rows from a DuckDB serving table."""
|
"""Fetch rows from a DuckDB serving table. limit=0 means all rows."""
|
||||||
assert "." in data_table, "data_table must be schema-qualified"
|
assert "." in data_table, "data_table must be schema-qualified"
|
||||||
_validate_table_name(data_table)
|
_validate_table_name(data_table)
|
||||||
|
|
||||||
order_clause = f"ORDER BY {order_by} DESC" if order_by else ""
|
order_clause = f"ORDER BY {order_by} DESC" if order_by else ""
|
||||||
return await fetch_analytics(
|
if limit:
|
||||||
f"SELECT * FROM {data_table} {order_clause} LIMIT ?",
|
return await fetch_analytics(
|
||||||
[limit],
|
f"SELECT * FROM {data_table} {order_clause} LIMIT ?",
|
||||||
)
|
[limit],
|
||||||
|
)
|
||||||
|
return await fetch_analytics(f"SELECT * FROM {data_table} {order_clause}")
|
||||||
|
|
||||||
|
|
||||||
async def count_template_data(data_table: str) -> int:
|
async def count_template_data(data_table: str) -> int:
|
||||||
@@ -290,7 +292,7 @@ async def generate_articles(
|
|||||||
start_date: date,
|
start_date: date,
|
||||||
articles_per_day: int,
|
articles_per_day: int,
|
||||||
*,
|
*,
|
||||||
limit: int = 500,
|
limit: int = 0,
|
||||||
base_url: str = "https://padelnomics.io",
|
base_url: str = "https://padelnomics.io",
|
||||||
task_id: int | None = None,
|
task_id: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Migration 0027: Affiliate programs table + program FK on products.
|
||||||
|
|
||||||
|
affiliate_programs: centralises retailer configs (URL template + tag + commission).
|
||||||
|
- url_template uses {product_id} and {tag} placeholders, assembled at redirect time.
|
||||||
|
- tracking_tag: e.g. "padelnomics-21" — changing it propagates to all products instantly.
|
||||||
|
- commission_pct: stored as a decimal (0.03 = 3%) for revenue estimates.
|
||||||
|
- status: active/inactive — only active programs appear in the product form dropdown.
|
||||||
|
- notes: internal field for login URLs, account IDs, etc.
|
||||||
|
|
||||||
|
affiliate_products changes:
|
||||||
|
- program_id (nullable FK): new products use a program; existing products keep their
|
||||||
|
baked affiliate_url (backward compat via build_affiliate_url() fallback).
|
||||||
|
- product_identifier: ASIN, product path, or other program-specific ID (e.g. B0XXXXX).
|
||||||
|
|
||||||
|
Amazon OneLink note: we use a single "Amazon" program pointing to amazon.de.
|
||||||
|
Amazon OneLink (configured in the Associates dashboard, no code changes needed)
|
||||||
|
auto-redirects visitors to their local marketplace (UK→amazon.co.uk, ES→amazon.es)
|
||||||
|
with the correct regional tag. One program covers all Amazon marketplaces.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def up(conn) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE affiliate_programs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
url_template TEXT NOT NULL,
|
||||||
|
tracking_tag TEXT NOT NULL DEFAULT '',
|
||||||
|
commission_pct REAL NOT NULL DEFAULT 0,
|
||||||
|
homepage_url TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed the default Amazon program.
|
||||||
|
# OneLink handles geo-redirect to local marketplaces — no per-country programs needed.
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO affiliate_programs (name, slug, url_template, tracking_tag, commission_pct, homepage_url)
|
||||||
|
VALUES ('Amazon', 'amazon', 'https://www.amazon.de/dp/{product_id}?tag={tag}', 'padelnomics-21', 3.0, 'https://www.amazon.de')
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add program FK + product identifier to products table.
|
||||||
|
# program_id is nullable — existing rows keep their baked affiliate_url.
|
||||||
|
conn.execute("""
|
||||||
|
ALTER TABLE affiliate_products
|
||||||
|
ADD COLUMN program_id INTEGER REFERENCES affiliate_programs(id)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
ALTER TABLE affiliate_products
|
||||||
|
ADD COLUMN product_identifier TEXT NOT NULL DEFAULT ''
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Backfill: extract ASIN from existing Amazon affiliate URLs.
|
||||||
|
# Pattern: /dp/<ASIN> where ASIN is 10 uppercase alphanumeric chars.
|
||||||
|
amazon_program = conn.execute(
|
||||||
|
"SELECT id FROM affiliate_programs WHERE slug = 'amazon'"
|
||||||
|
).fetchone()
|
||||||
|
assert amazon_program is not None, "Amazon program must exist after seed"
|
||||||
|
amazon_id = amazon_program[0]
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, affiliate_url FROM affiliate_products"
|
||||||
|
).fetchall()
|
||||||
|
asin_re = re.compile(r"/dp/([A-Z0-9]{10})")
|
||||||
|
for product_id, url in rows:
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
m = asin_re.search(url)
|
||||||
|
if m:
|
||||||
|
asin = m.group(1)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE affiliate_products SET program_id=?, product_identifier=? WHERE id=?",
|
||||||
|
(amazon_id, asin, product_id),
|
||||||
|
)
|
||||||
@@ -218,9 +218,7 @@
|
|||||||
.nav-bar[data-navopen="true"] .nav-mobile {
|
.nav-bar[data-navopen="true"] .nav-mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
.nav-mobile a,
|
.nav-mobile a:not(.nav-auth-btn) {
|
||||||
.nav-mobile button.nav-auth-btn,
|
|
||||||
.nav-mobile a.nav-auth-btn {
|
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.625rem 0;
|
padding: 0.625rem 0;
|
||||||
border-bottom: 1px solid #F1F5F9;
|
border-bottom: 1px solid #F1F5F9;
|
||||||
@@ -230,15 +228,18 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
.nav-mobile a:last-child { border-bottom: none; }
|
.nav-mobile a:not(.nav-auth-btn):last-child { border-bottom: none; }
|
||||||
.nav-mobile a:hover { color: #1D4ED8; }
|
.nav-mobile a:not(.nav-auth-btn):hover { color: #1D4ED8; }
|
||||||
|
/* nav-auth-btn in mobile menu: override block style, keep button colours */
|
||||||
.nav-mobile a.nav-auth-btn,
|
.nav-mobile a.nav-auth-btn,
|
||||||
.nav-mobile button.nav-auth-btn {
|
.nav-mobile button.nav-auth-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
padding: 6px 16px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
width: auto;
|
width: auto;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.nav-mobile .nav-mobile-section {
|
.nav-mobile .nav-mobile-section {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
|
|||||||
@@ -735,6 +735,107 @@ async def handle_run_extraction(payload: dict) -> None:
|
|||||||
logger.info("Extraction completed: %s", result.stdout[-300:] if result.stdout else "(no output)")
|
logger.info("Extraction completed: %s", result.stdout[-300:] if result.stdout else "(no output)")
|
||||||
|
|
||||||
|
|
||||||
|
@task("run_transform")
|
||||||
|
async def handle_run_transform(payload: dict) -> None:
|
||||||
|
"""Run SQLMesh transform (prod plan --auto-apply) in the background.
|
||||||
|
|
||||||
|
Shells out to `uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply`.
|
||||||
|
2-hour absolute timeout — same as extraction.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[4]
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=7200,
|
||||||
|
cwd=str(repo_root),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"SQLMesh transform failed (exit {result.returncode}): {result.stderr[:500]}"
|
||||||
|
)
|
||||||
|
logger.info("SQLMesh transform completed: %s", result.stdout[-300:] if result.stdout else "(no output)")
|
||||||
|
|
||||||
|
|
||||||
|
@task("run_export")
|
||||||
|
async def handle_run_export(payload: dict) -> None:
|
||||||
|
"""Export serving tables from lakehouse.duckdb → analytics.duckdb.
|
||||||
|
|
||||||
|
Shells out to `uv run python src/padelnomics/export_serving.py`.
|
||||||
|
10-minute absolute timeout.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[4]
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
["uv", "run", "python", "src/padelnomics/export_serving.py"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
cwd=str(repo_root),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Export failed (exit {result.returncode}): {result.stderr[:500]}"
|
||||||
|
)
|
||||||
|
logger.info("Export completed: %s", result.stdout[-300:] if result.stdout else "(no output)")
|
||||||
|
|
||||||
|
|
||||||
|
@task("run_pipeline")
|
||||||
|
async def handle_run_pipeline(payload: dict) -> None:
|
||||||
|
"""Run full ELT pipeline: extract → transform → export, stopping on first failure."""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[4]
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
(
|
||||||
|
"extraction",
|
||||||
|
["uv", "run", "--package", "padelnomics_extract", "extract"],
|
||||||
|
7200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"transform",
|
||||||
|
["uv", "run", "sqlmesh", "-p", "transform/sqlmesh_padelnomics", "plan", "prod", "--auto-apply"],
|
||||||
|
7200,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"export",
|
||||||
|
["uv", "run", "python", "src/padelnomics/export_serving.py"],
|
||||||
|
600,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for step_name, cmd, timeout_seconds in steps:
|
||||||
|
logger.info("Pipeline step starting: %s", step_name)
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
cwd=str(repo_root),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Pipeline failed at {step_name} (exit {result.returncode}): {result.stderr[:500]}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Pipeline step complete: %s — %s",
|
||||||
|
step_name,
|
||||||
|
result.stdout[-200:] if result.stdout else "(no output)",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Full pipeline complete (extract → transform → export)")
|
||||||
|
|
||||||
|
|
||||||
@task("generate_articles")
|
@task("generate_articles")
|
||||||
async def handle_generate_articles(payload: dict) -> None:
|
async def handle_generate_articles(payload: dict) -> None:
|
||||||
"""Generate articles from a template in the background."""
|
"""Generate articles from a template in the background."""
|
||||||
@@ -745,7 +846,7 @@ async def handle_generate_articles(payload: dict) -> None:
|
|||||||
slug = payload["template_slug"]
|
slug = payload["template_slug"]
|
||||||
start_date = date_cls.fromisoformat(payload["start_date"])
|
start_date = date_cls.fromisoformat(payload["start_date"])
|
||||||
articles_per_day = payload.get("articles_per_day", 3)
|
articles_per_day = payload.get("articles_per_day", 3)
|
||||||
limit = payload.get("limit", 500)
|
limit = payload.get("limit", 0)
|
||||||
task_id = payload.get("_task_id")
|
task_id = payload.get("_task_id")
|
||||||
|
|
||||||
count = await generate_articles(
|
count = await generate_articles(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
Tests for the affiliate product system.
|
Tests for the affiliate product system.
|
||||||
|
|
||||||
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
|
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
|
||||||
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer.
|
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer,
|
||||||
|
program CRUD, build_affiliate_url(), program-based redirect.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -10,11 +11,15 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from padelnomics.affiliate import (
|
from padelnomics.affiliate import (
|
||||||
|
build_affiliate_url,
|
||||||
get_all_products,
|
get_all_products,
|
||||||
|
get_all_programs,
|
||||||
get_click_counts,
|
get_click_counts,
|
||||||
get_click_stats,
|
get_click_stats,
|
||||||
get_product,
|
get_product,
|
||||||
get_products_by_category,
|
get_products_by_category,
|
||||||
|
get_program,
|
||||||
|
get_program_by_slug,
|
||||||
hash_ip,
|
hash_ip,
|
||||||
log_click,
|
log_click,
|
||||||
)
|
)
|
||||||
@@ -330,3 +335,282 @@ async def test_affiliate_redirect_unknown_404(app, db):
|
|||||||
async with app.test_client() as client:
|
async with app.test_client() as client:
|
||||||
response = await client.get("/go/totally-unknown-xyz")
|
response = await client.get("/go/totally-unknown-xyz")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── affiliate_programs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _insert_program(
|
||||||
|
name="Test Shop",
|
||||||
|
slug="test-shop",
|
||||||
|
url_template="https://testshop.example.com/p/{product_id}?ref={tag}",
|
||||||
|
tracking_tag="testref",
|
||||||
|
commission_pct=5.0,
|
||||||
|
homepage_url="https://testshop.example.com",
|
||||||
|
status="active",
|
||||||
|
) -> int:
|
||||||
|
"""Insert an affiliate program, return its id."""
|
||||||
|
return await execute(
|
||||||
|
"""INSERT INTO affiliate_programs
|
||||||
|
(name, slug, url_template, tracking_tag, commission_pct, homepage_url, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(name, slug, url_template, tracking_tag, commission_pct, homepage_url, status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_all_programs_returns_all(db):
|
||||||
|
"""get_all_programs returns inserted programs sorted by name."""
|
||||||
|
await _insert_program(slug="zebra-shop", name="Zebra Shop")
|
||||||
|
await _insert_program(slug="alpha-shop", name="Alpha Shop")
|
||||||
|
programs = await get_all_programs()
|
||||||
|
names = [p["name"] for p in programs]
|
||||||
|
assert "Alpha Shop" in names
|
||||||
|
assert "Zebra Shop" in names
|
||||||
|
# Sorted by name ascending
|
||||||
|
assert names.index("Alpha Shop") < names.index("Zebra Shop")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_all_programs_status_filter(db):
|
||||||
|
"""get_all_programs(status='active') excludes inactive programs."""
|
||||||
|
await _insert_program(slug="inactive-prog", status="inactive")
|
||||||
|
await _insert_program(slug="active-prog", name="Active Shop")
|
||||||
|
active = await get_all_programs(status="active")
|
||||||
|
statuses = [p["status"] for p in active]
|
||||||
|
assert all(s == "active" for s in statuses)
|
||||||
|
slugs = [p["slug"] for p in active]
|
||||||
|
assert "inactive-prog" not in slugs
|
||||||
|
assert "active-prog" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_by_id(db):
|
||||||
|
"""get_program returns a program by id."""
|
||||||
|
prog_id = await _insert_program()
|
||||||
|
prog = await get_program(prog_id)
|
||||||
|
assert prog is not None
|
||||||
|
assert prog["slug"] == "test-shop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_not_found(db):
|
||||||
|
"""get_program returns None for unknown id."""
|
||||||
|
prog = await get_program(99999)
|
||||||
|
assert prog is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_by_slug(db):
|
||||||
|
"""get_program_by_slug returns the program for a known slug."""
|
||||||
|
await _insert_program(slug="find-by-slug")
|
||||||
|
prog = await get_program_by_slug("find-by-slug")
|
||||||
|
assert prog is not None
|
||||||
|
assert prog["name"] == "Test Shop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_program_by_slug_not_found(db):
|
||||||
|
"""get_program_by_slug returns None for unknown slug."""
|
||||||
|
prog = await get_program_by_slug("nonexistent-slug-xyz")
|
||||||
|
assert prog is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_get_all_programs_product_count(db):
|
||||||
|
"""get_all_programs includes product_count for each program."""
|
||||||
|
prog_id = await _insert_program(slug="counted-prog")
|
||||||
|
await _insert_product(slug="p-for-count", program_id=prog_id)
|
||||||
|
programs = await get_all_programs()
|
||||||
|
prog = next(p for p in programs if p["slug"] == "counted-prog")
|
||||||
|
assert prog["product_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_affiliate_url ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_affiliate_url_with_program():
|
||||||
|
"""build_affiliate_url assembles URL from program template."""
|
||||||
|
product = {"program_id": 1, "product_identifier": "B0TESTTEST", "affiliate_url": ""}
|
||||||
|
program = {"url_template": "https://amazon.de/dp/{product_id}?tag={tag}", "tracking_tag": "mysite-21"}
|
||||||
|
url = build_affiliate_url(product, program)
|
||||||
|
assert url == "https://amazon.de/dp/B0TESTTEST?tag=mysite-21"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_affiliate_url_legacy_fallback():
|
||||||
|
"""build_affiliate_url falls back to baked affiliate_url when no program."""
|
||||||
|
product = {"program_id": None, "product_identifier": "", "affiliate_url": "https://baked.example.com/p?tag=x"}
|
||||||
|
url = build_affiliate_url(product, None)
|
||||||
|
assert url == "https://baked.example.com/p?tag=x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_affiliate_url_no_program_id():
|
||||||
|
"""build_affiliate_url uses fallback when program_id is 0/falsy."""
|
||||||
|
product = {"program_id": 0, "product_identifier": "B0IGNORED", "affiliate_url": "https://fallback.example.com"}
|
||||||
|
program = {"url_template": "https://shop.example.com/{product_id}?ref={tag}", "tracking_tag": "tag123"}
|
||||||
|
url = build_affiliate_url(product, program)
|
||||||
|
# program_id is falsy → fallback
|
||||||
|
assert url == "https://fallback.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_affiliate_url_no_program_dict():
|
||||||
|
"""build_affiliate_url uses fallback when program dict is None."""
|
||||||
|
product = {"program_id": 5, "product_identifier": "ASIN123", "affiliate_url": "https://fallback.example.com"}
|
||||||
|
url = build_affiliate_url(product, None)
|
||||||
|
assert url == "https://fallback.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ── program-based redirect ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _insert_product( # noqa: F811 — redefined to add program_id support
|
||||||
|
slug="test-racket-amazon",
|
||||||
|
name="Test Racket",
|
||||||
|
brand="TestBrand",
|
||||||
|
category="racket",
|
||||||
|
retailer="Amazon",
|
||||||
|
affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
|
||||||
|
status="active",
|
||||||
|
language="de",
|
||||||
|
price_cents=14999,
|
||||||
|
pros=None,
|
||||||
|
cons=None,
|
||||||
|
sort_order=0,
|
||||||
|
program_id=None,
|
||||||
|
product_identifier="",
|
||||||
|
) -> int:
|
||||||
|
"""Insert an affiliate product with optional program_id, return its id."""
|
||||||
|
return await execute(
|
||||||
|
"""INSERT INTO affiliate_products
|
||||||
|
(slug, name, brand, category, retailer, affiliate_url,
|
||||||
|
price_cents, currency, status, language, pros, cons, sort_order,
|
||||||
|
program_id, product_identifier)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
slug, name, brand, category, retailer, affiliate_url,
|
||||||
|
price_cents, status, language,
|
||||||
|
json.dumps(pros or ["Gut"]),
|
||||||
|
json.dumps(cons or ["Teuer"]),
|
||||||
|
sort_order,
|
||||||
|
program_id,
|
||||||
|
product_identifier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_affiliate_redirect_uses_program_url(app, db):
|
||||||
|
"""Redirect assembles URL from program template when product has program_id."""
|
||||||
|
prog_id = await _insert_program(
|
||||||
|
slug="amzn-test",
|
||||||
|
url_template="https://www.amazon.de/dp/{product_id}?tag={tag}",
|
||||||
|
tracking_tag="testsite-21",
|
||||||
|
)
|
||||||
|
await _insert_product(
|
||||||
|
slug="program-redirect-test",
|
||||||
|
affiliate_url="",
|
||||||
|
program_id=prog_id,
|
||||||
|
product_identifier="B0PROGRAM01",
|
||||||
|
)
|
||||||
|
async with app.test_client() as client:
|
||||||
|
response = await client.get("/go/program-redirect-test")
|
||||||
|
assert response.status_code == 302
|
||||||
|
location = response.headers.get("Location", "")
|
||||||
|
assert "B0PROGRAM01" in location
|
||||||
|
assert "testsite-21" in location
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db")
|
||||||
|
async def test_affiliate_redirect_legacy_url_still_works(app, db):
|
||||||
|
"""Legacy products with baked affiliate_url still redirect correctly."""
|
||||||
|
await _insert_product(
|
||||||
|
slug="legacy-redirect-test",
|
||||||
|
affiliate_url="https://amazon.de/dp/LEGACY?tag=old-21",
|
||||||
|
program_id=None,
|
||||||
|
product_identifier="",
|
||||||
|
)
|
||||||
|
async with app.test_client() as client:
|
||||||
|
response = await client.get("/go/legacy-redirect-test")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "LEGACY" in response.headers.get("Location", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ── migration backfill ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_migration_0027():
|
||||||
|
"""Import migration 0027 via importlib (filename starts with a digit)."""
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
versions_dir = Path(__file__).parent.parent / "src/padelnomics/migrations/versions"
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"migration_0027", versions_dir / "0027_affiliate_programs.py"
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pre_migration_db():
|
||||||
|
"""Create a minimal sqlite3 DB simulating state just before migration 0027.
|
||||||
|
|
||||||
|
Provides the affiliate_products table (migration ALTERs it), but not
|
||||||
|
affiliate_programs (migration CREATEs it).
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE affiliate_products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
affiliate_url TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(slug)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_seeds_amazon_program():
|
||||||
|
"""Migration 0027 up() seeds the Amazon program with expected fields.
|
||||||
|
|
||||||
|
Tests the migration function directly against a real sqlite3 DB
|
||||||
|
(the conftest only replays CREATE TABLE DDL, not INSERT seeds).
|
||||||
|
"""
|
||||||
|
migration = _load_migration_0027()
|
||||||
|
conn = _make_pre_migration_db()
|
||||||
|
migration.up(conn)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM affiliate_programs WHERE slug = 'amazon'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
cols = [d[0] for d in conn.execute("SELECT * FROM affiliate_programs WHERE slug = 'amazon'").description]
|
||||||
|
prog = dict(zip(cols, row))
|
||||||
|
assert prog["name"] == "Amazon"
|
||||||
|
assert "padelnomics-21" in prog["tracking_tag"]
|
||||||
|
assert "{product_id}" in prog["url_template"]
|
||||||
|
assert "{tag}" in prog["url_template"]
|
||||||
|
assert prog["commission_pct"] == 3.0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_backfills_asin_from_url():
|
||||||
|
"""Migration 0027 up() extracts ASINs from existing affiliate_url values."""
|
||||||
|
migration = _load_migration_0027()
|
||||||
|
conn = _make_pre_migration_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO affiliate_products (slug, affiliate_url) VALUES (?, ?)",
|
||||||
|
("test-racket", "https://www.amazon.de/dp/B0ASIN1234?tag=test-21"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
migration.up(conn)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT program_id, product_identifier FROM affiliate_products WHERE slug = 'test-racket'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] is not None # program_id set
|
||||||
|
assert row[1] == "B0ASIN1234" # ASIN extracted correctly
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -500,3 +500,131 @@ class TestTieredCyclerNTier:
|
|||||||
t.join()
|
t.join()
|
||||||
|
|
||||||
assert errors == [], f"Thread safety errors: {errors}"
|
assert errors == [], f"Thread safety errors: {errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTieredCyclerDeadProxyTracking:
|
||||||
|
"""Per-proxy dead tracking: individual proxies marked dead are skipped."""
|
||||||
|
|
||||||
|
def test_dead_proxy_skipped_in_next_proxy(self):
|
||||||
|
"""After a proxy hits the failure limit it is never returned again."""
|
||||||
|
tiers = [["http://dead", "http://live"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=1)
|
||||||
|
# Mark http://dead as dead
|
||||||
|
cycler["record_failure"]("http://dead")
|
||||||
|
# next_proxy must always return the live one
|
||||||
|
for _ in range(6):
|
||||||
|
assert cycler["next_proxy"]() == "http://live"
|
||||||
|
|
||||||
|
def test_dead_proxy_count_increments(self):
|
||||||
|
tiers = [["http://a", "http://b", "http://c"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=2)
|
||||||
|
assert cycler["dead_proxy_count"]() == 0
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 0 # only 1 failure, limit is 2
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 1
|
||||||
|
cycler["record_failure"]("http://b")
|
||||||
|
cycler["record_failure"]("http://b")
|
||||||
|
assert cycler["dead_proxy_count"]() == 2
|
||||||
|
|
||||||
|
def test_auto_escalates_when_all_proxies_in_tier_dead(self):
|
||||||
|
"""If all proxies in the active tier are dead, next_proxy auto-escalates."""
|
||||||
|
tiers = [["http://t0a", "http://t0b"], ["http://t1"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=1)
|
||||||
|
# Kill all proxies in tier 0
|
||||||
|
cycler["record_failure"]("http://t0a")
|
||||||
|
cycler["record_failure"]("http://t0b")
|
||||||
|
# next_proxy should transparently escalate and return tier 1 proxy
|
||||||
|
assert cycler["next_proxy"]() == "http://t1"
|
||||||
|
|
||||||
|
def test_auto_escalates_updates_active_tier_index(self):
|
||||||
|
"""Auto-escalation via dead proxies bumps active_tier_index."""
|
||||||
|
tiers = [["http://t0a", "http://t0b"], ["http://t1"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=1)
|
||||||
|
cycler["record_failure"]("http://t0a")
|
||||||
|
cycler["record_failure"]("http://t0b")
|
||||||
|
cycler["next_proxy"]() # triggers auto-escalation
|
||||||
|
assert cycler["active_tier_index"]() == 1
|
||||||
|
|
||||||
|
def test_returns_none_when_all_tiers_exhausted_by_dead_proxies(self):
|
||||||
|
tiers = [["http://t0"], ["http://t1"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=1)
|
||||||
|
cycler["record_failure"]("http://t0")
|
||||||
|
cycler["record_failure"]("http://t1")
|
||||||
|
assert cycler["next_proxy"]() is None
|
||||||
|
|
||||||
|
def test_record_success_resets_per_proxy_counter(self):
|
||||||
|
"""Success resets the failure count so proxy is not marked dead."""
|
||||||
|
tiers = [["http://a", "http://b"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=3)
|
||||||
|
# Two failures — not dead yet
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 0
|
||||||
|
# Success resets the counter
|
||||||
|
cycler["record_success"]("http://a")
|
||||||
|
# Two more failures — still not dead (counter was reset)
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 0
|
||||||
|
# Third failure after reset — now dead
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 1
|
||||||
|
|
||||||
|
def test_dead_proxy_stays_dead_after_success(self):
|
||||||
|
"""Once marked dead, a proxy is not revived by record_success."""
|
||||||
|
tiers = [["http://a", "http://b"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=1)
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 1
|
||||||
|
cycler["record_success"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 1
|
||||||
|
# http://a is still skipped
|
||||||
|
for _ in range(6):
|
||||||
|
assert cycler["next_proxy"]() == "http://b"
|
||||||
|
|
||||||
|
def test_backward_compat_no_proxy_url(self):
|
||||||
|
"""Calling record_failure/record_success without proxy_url still works."""
|
||||||
|
tiers = [["http://t0"], ["http://t1"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=2)
|
||||||
|
cycler["record_failure"]()
|
||||||
|
cycler["record_failure"]() # escalates
|
||||||
|
assert cycler["active_tier_index"]() == 1
|
||||||
|
cycler["record_success"]()
|
||||||
|
assert cycler["dead_proxy_count"]() == 0 # no per-proxy tracking happened
|
||||||
|
|
||||||
|
def test_proxy_failure_limit_zero_disables_per_proxy_tracking(self):
|
||||||
|
"""proxy_failure_limit=0 disables per-proxy dead tracking entirely."""
|
||||||
|
tiers = [["http://a", "http://b"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=10, proxy_failure_limit=0)
|
||||||
|
for _ in range(100):
|
||||||
|
cycler["record_failure"]("http://a")
|
||||||
|
assert cycler["dead_proxy_count"]() == 0
|
||||||
|
|
||||||
|
def test_thread_safety_with_per_proxy_tracking(self):
|
||||||
|
"""Concurrent record_failure(proxy_url) calls don't corrupt state."""
|
||||||
|
import threading as _threading
|
||||||
|
|
||||||
|
tiers = [["http://t0a", "http://t0b", "http://t0c"], ["http://t1a"]]
|
||||||
|
cycler = make_tiered_cycler(tiers, threshold=50, proxy_failure_limit=5)
|
||||||
|
errors = []
|
||||||
|
lock = _threading.Lock()
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
for _ in range(30):
|
||||||
|
p = cycler["next_proxy"]()
|
||||||
|
if p is not None:
|
||||||
|
cycler["record_failure"](p)
|
||||||
|
cycler["record_success"](p)
|
||||||
|
except Exception as e:
|
||||||
|
with lock:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
threads = [_threading.Thread(target=worker) for _ in range(10)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
assert errors == [], f"Thread safety errors: {errors}"
|
||||||
|
|||||||
Reference in New Issue
Block a user