Compare commits
37 Commits
8e136351f9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e022a7dcc | |||
| 386af1c5ed | |||
| aca5051418 | |||
| 7ca5d7d94e | |||
| bc59d1dc2d | |||
| ae9c4e2bfd | |||
| 6e5ada8517 | |||
| 47df057270 | |||
| 7625e59173 | |||
| c4640ca3af | |||
| 38383d07c5 | |||
| 9b0ed43b92 | |||
| 55a27fb315 | |||
| efa15d0086 | |||
| f488623920 | |||
| b72d037141 | |||
| 8127e2ef22 | |||
| 7608839473 | |||
| 1bc30d7881 | |||
| 80ed621683 | |||
| a428363d42 | |||
| f07fdad919 | |||
| 007825acee | |||
| 1e611d59f1 | |||
| 448273945c | |||
| 360b8008ba | |||
| ce544e660c | |||
| f7b5114f58 | |||
| c6a5ec62aa | |||
| 16b980e842 | |||
| 1d02c0897d | |||
| 9fb512932a | |||
| c68fd4471e | |||
| a20230187f | |||
| e07292ba44 | |||
| a0fb328029 | |||
| dd2f7daaf8 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"3eea00ef-fccd-4683-85c6-f7d39e8199a7","pid":1940465,"procStart":"327348495","acquiredAt":1777964592489}
|
||||
@@ -1,72 +0,0 @@
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Ri.NET CENTRAL ENVIRONMENT
|
||||
# Author: Damir Radulić | Updated: 25.04.2026
|
||||
# Source: /opt/MASTER_CREDENTIALS_v5.md
|
||||
# Permissions: 600 root:root
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
# === SERVERS ===
|
||||
GPU_HOST=144.76.68.5
|
||||
GPU_SSH_PORT=22
|
||||
GPU_SSH_PASS='Gnu?CfR9hDBaER'
|
||||
|
||||
# === BRIDGE API ===
|
||||
BRIDGE_URL=https://api.rinet.one/bridge/exec
|
||||
BRIDGE_KEY=rinet-yS4ZnKlwUqsjk
|
||||
|
||||
# === DATABASE (PostgreSQL 18 on GPU) ===
|
||||
PG_HOST=10.10.0.2
|
||||
PG_PORT=6432
|
||||
PG_DB=rinet_v3
|
||||
PG_USER=rinet
|
||||
PG_PASS='R1net2026!SecureDB#v7'
|
||||
PG_PASS_POSTGRES='5852Dan1TR5852'
|
||||
PG_BOUNCER_PORT=6432
|
||||
DATABASE_URL=postgresql://rinet:R1net2026!SecureDB#v7@10.10.0.2:6432/rinet_v3
|
||||
|
||||
# === REDIS ===
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASS='R1netRedis2026v3'
|
||||
|
||||
# === HETZNER (DNS automatski!) ===
|
||||
HETZNER_API_KEY='y2vBcp6QzkEvljhM0ujoazrJuiR7pi4pmtjTV276xUYWWUBEEindz7ZGWqWgU5yT'
|
||||
HETZNER_DNS_TOKEN='iU5C2R60M4DUSUuqsIrJaRi3W1Hru8Dc'
|
||||
HETZNER_SERVER_ID=2957676
|
||||
ZONE_RINET_ONE=1005289
|
||||
ZONE_RINET_DEV=1005291
|
||||
ZONE_DABI_DIGITAL=1005292
|
||||
|
||||
# === CONTABO (legacy, samo WP) ===
|
||||
CONTABO_CLIENT_ID=INT-12360074
|
||||
CONTABO_SECRET=YAH3hF0BSdkf72hgH6vVjzdgrEWMTJZA
|
||||
CONTABO_USER=dradulic@outlook.com
|
||||
CONTABO_PASS='5852D@n1TR'
|
||||
|
||||
# === LLM API KEYS ===
|
||||
GROQ_API_KEY=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
|
||||
GEMINI_API_KEY=AIzaSyBHup6cmr8VkDm0l4uwBj5xRvuGA0W7XhI
|
||||
DEEPSEEK_API_KEY=sk-33d29054d1ab4377b7d1a84bc0a423c7
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
|
||||
# === ADMIN/AUTH ===
|
||||
ADMIN_EMAIL=admin@rinet.one
|
||||
ADMIN_PASS='R1net2026!Admin'
|
||||
GRAFANA_USER=admin
|
||||
GRAFANA_PASS='R1net2026!Admin'
|
||||
|
||||
# === MAIL (Poste.io) ===
|
||||
SMTP_HOST=mail.rinet.one
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=admin@rinet.one
|
||||
|
||||
# === PGZ SPORT ERP ===
|
||||
PGZ_SPORT_PORT=8095
|
||||
PGZ_SPORT_ADMIN_TOKEN=admin-pgz-2026
|
||||
PGZ_SPORT_VIEWER_TOKEN=viewer-pgz-2026
|
||||
|
||||
# === RUNTIME ===
|
||||
TZ=Europe/Zagreb
|
||||
LANG=hr_HR.UTF-8
|
||||
GITHUB_TOKEN=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
|
||||
GITHUB_PAT=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
|
||||
@@ -0,0 +1,119 @@
|
||||
# FULLSTACK SPRINT — KONSOLIDIRANI IZVJEŠTAJ
|
||||
|
||||
**Sprint ID:** fullstack_20260505_0858
|
||||
**Sprint trajao:** 09:00 → 09:25 (≈25 min, 5 paralelnih subagenata)
|
||||
**Compiled:** 2026-05-05 09:25 by orchestrator (Claude Opus 4.7 / 1M)
|
||||
|
||||
## TL;DR
|
||||
|
||||
| # | Subagent | Status | Live test | Persistencija |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Dashboard Top Primatelji UI | ✅ DONE | ✅ 5/5 curl pass | ✅ commit 31e0374 |
|
||||
| 2 | Role-based OIB display | ✅ DONE | ✅ 7/7 scope tests | ✅ commit 8e13635 |
|
||||
| 3 | GDPR consent verify + Art.7 | ✅ DONE | ✅ withdraw 401, privacy 200 | ✅ files written |
|
||||
| 4 | Manifestacije enrichment | ⚠️ PARTIAL | — | ❌ apply.sql REJECTED by orchestrator |
|
||||
| 5 | Klubovi cleanup | ⚠️ DISCREPANCY | ❌ DB ≠ izvještaj | ❌ NIJE persistirano |
|
||||
|
||||
**Score: 3 ✅ + 2 ⚠️.** Damir mora pregledati Sub4 i Sub5 ručno.
|
||||
|
||||
---
|
||||
|
||||
## Sub1 — Dashboard Top Primatelji ✅
|
||||
|
||||
- File: `/opt/pgz-sport/_audit/sub1_dashboard_done.md`
|
||||
- Commit: `31e0374`
|
||||
- **Backend** (`pgz_sport_api.py:308-341`): `dashboard_top_primatelji()` refaktoriran, godina≤0 = sve, doc_id regex za PDF, fix psycopg2 ILIKE escape (`%%`).
|
||||
- **Frontend** (`static/sport2.html:907-957`): dropdown `Sve|2026|2025*|2024|...`, default=2025, 7 kolona uključujući PDF link.
|
||||
- **Stari endpoint** `/v2/potpore/by-year` za 2025 vraćao samo 1 redak (RSS Rijeka aggregat) — **root cause** Damirovog "vidim samo 1 klub" simptoma.
|
||||
- **Live:** 2025=13 redaka, 2026=120 redaka, sve godine=0 fallback.
|
||||
|
||||
## Sub2 — Role-based OIB ✅
|
||||
|
||||
- File: `/opt/pgz-sport/_audit/sub2_oib_done.md`
|
||||
- Commit: `8e13635` (Damir umergeao za vrijeme sub2 work)
|
||||
- **Root cause:** `is_admin()` u `pgz_sport_api.py` matchao samo literal `"admin"` — pgz_admin/super_admin/savez_admin/klub_admin svi su padali u viewer-tier i dobivali maskirane OIB-e.
|
||||
- Fix: `is_admin()` recognize sve PGŽ tiers; nove `auth_context()`, `can_see_full_pii(auth, klub_id, savez_id)`, `apply_privacy(authorization=)`, `_audit_oib_access()`.
|
||||
- **Frontend:** `/static/oib_format.js` — single source of truth, `<script src="/static/oib_format.js" defer>` u 11 .html file-ova.
|
||||
- **Audit log:** svaki čitanje punog OIB-a → `pgz_sport.audit_events` (action `oib.read`, reason `legitimate_interest`).
|
||||
- **Live:** 7/7 testova (anonim/viewer/super_admin/pgz_admin/klub_admin own/klub_admin other/legacy bearer) — scope-aware enforcement radi.
|
||||
|
||||
## Sub3 — GDPR ✅
|
||||
|
||||
- File: `/opt/pgz-sport/_audit/sub3_gdpr_done.md`
|
||||
- **Status modula:** real, not skeleton — `auth/gdpr.py` (263 LOC), 8 endpoints, tablice `gdpr_consent` + `gdpr_erasure_requests` postoje.
|
||||
- Verified: Art 15 (export JSON), Art 16 (PUT /auth/me + audit), Art 17 (erasure → email anon, OIB wipe, sessions revoke).
|
||||
- **Trivial fixes applied:**
|
||||
1. **Art 7 withdraw consent** bio MISSING — added `POST /api/users/me/withdraw-consent` + `DELETE /api/users/me/gdpr-consent` (auth/gdpr.py:209-232). Live HTTP 200/401.
|
||||
2. **`/api/gdpr/policy`** referencirao `/sport/static/privacy.html` koji NIJE postojao — kreiran 10842 B Palantir-style privacy policy. Live: HTTP 200 na `https://api.rinet.one/sport/static/privacy.html`.
|
||||
- **Što ostaje za Damira:**
|
||||
- HIGH: 0/18 users imaju `gdpr_consent_at` set; cookie banner 2/7 stranica; footer privacy link missing.
|
||||
- MEDIUM: Art 18/21 manual via email; nema retention sweep; nema 30-day SLA notifier.
|
||||
- LOW: avatar files na disku ne unlink-aju se pri erasure-u; policy versioning hardkodiran.
|
||||
|
||||
## Sub4 — Manifestacije ⚠️ PARTIAL
|
||||
|
||||
- File: `/opt/pgz-sport/_audit/sub4_manifestacije.md`
|
||||
- **Status:** agent prekinut prije završetka, obradio 50/113 redova.
|
||||
- **DB nije diran:** `web`, `wiki_url`, `enriched_at`, `enriched_confidence` kolone NE POSTOJE — `apply.sql` napisan ali NIJE pokrenut.
|
||||
- **Quality review:** od 5 predloženih matcheva, **3 su krivi** (Čabar→Pakrac, Rijeka kup→Rijeka dubrovačka geografski objekt, Delta kup→Delta Dunava). Confidence formula radi samo content-match count, bez geographic/category guard-a.
|
||||
- **Orchestrator decision:** `apply.sql` REJECTED. Samo Rally Opatija (id=5) bi se mogao primijeniti ručno.
|
||||
- **Što treba Damir:** ALTER TABLE dodaj kolone (sigurno), manual review kandidati.csv, re-run skripte s edit-distance + category guard.
|
||||
|
||||
## Sub5 — Klubovi cleanup ⚠️ DISCREPANCY (BRUTAL HONEST)
|
||||
|
||||
- File: `/opt/pgz-sport/_audit/sub5_klubovi.md`
|
||||
- **Sub5 izvještaj tvrdi:** 13 sub5a-flagged + 49 KUD reclassified u 'lovstvo'.
|
||||
- **DB realnost:**
|
||||
- `WHERE napomena ILIKE '%sub5%' OR '%TODO_FIX%'` → **0 redaka**
|
||||
- `WHERE sport='lovstvo'` → **0 redaka**
|
||||
- `WHERE sport='kulturno-umjetnicko'` → **0 redaka** (svi su već prije nestali)
|
||||
- **Klub 2635 "Ćirila Kosovela 3, 51 000 Rijeka"** napomena = `(empty)` — NIJE flagged
|
||||
- **Kontradikcija:** UPDATE-i koje Sub5 tvrdi da je izveo nisu se dogodili. Ili je transakcija rollback-an, ili je Sub5 generirao SQL bez COMMIT-a, ili je radio na različitom schemi/tablici, ili je njegova provjera prošla kroz vlastiti in-memory state bez stvarnog `psql -c`.
|
||||
- **Sub5 file artifact-i (sub5_klubovi/run_sub5.py, sub5_run.json) postoje**, ali stvarni DB UPDATE rezultat = 0.
|
||||
- **Što treba Damir:** ručno pregledati `sub5_klubovi/sub5_run.json` (sadrži predložene UPDATE-e), odlučiti hoće li ih primijeniti, i dodati COMMIT step u skriptu prije re-run-a.
|
||||
|
||||
---
|
||||
|
||||
## Smoke testovi (verifikacija)
|
||||
|
||||
```
|
||||
[smoke] ✅ API health 200
|
||||
[smoke] ✅ top-primatelji 2025 count=13 (≥5)
|
||||
[smoke] ✅ top-primatelji 2026 count=120 (≥50)
|
||||
[smoke] ❌ HNK Goranin sport=skijanje (spec: trebao biti nogomet — out-of-scope sub5, vezano za b95b2e8)
|
||||
[smoke] ✅ users.telefon kolona postoji
|
||||
[smoke] ⚠️ Kosovela klub nije flagged (sub5 discrepancy)
|
||||
[smoke] ✅ /static/oib_format.js HTTP 200
|
||||
[smoke] ✅ /static/privacy.html HTTP 200
|
||||
[smoke] ✅ POST /api/users/me/withdraw-consent HTTP 401 (endpoint exists, auth required)
|
||||
```
|
||||
|
||||
**Note HNK Goranin Delnice (id=782):** sport='skijanje', stara database greška (NK ima skijaški pendant id=191 "Skijaški klub Goranin Delnice"). Sub5 nije adresirao single-klub fix. Treba SQL update:
|
||||
```sql
|
||||
UPDATE pgz_sport.klubovi SET sport='nogomet' WHERE id=782;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coordination
|
||||
|
||||
- Heartbeat: ažuriran više puta (Redis `cc:pgz-sport:heartbeat`)
|
||||
- Log: 5 push-eva u `cc:pgz-sport:log` (start, sub1-5 done, sprint complete)
|
||||
- Workers: nema kolizije s W6 (CC4 ERP), W7 (CC5 frontend), W8 (CC6 vector)
|
||||
|
||||
## Files modified (po commitu)
|
||||
|
||||
- `31e0374` — Dashboard top primatelji (Sub1): pgz_sport_api.py, static/sport2.html
|
||||
- `8e13635` — OIB role + login crisis (Sub2 + Damir): pgz_sport_api.py, 11 .html, /static/oib_format.js
|
||||
- (uncommitted) — Sub3: auth/gdpr.py + new static/privacy.html
|
||||
- (rejected) — Sub4: sub4_manifestacije_apply.sql
|
||||
- (no-op) — Sub5: tvrdi UPDATE 62 redaka, DB pokazuje 0
|
||||
|
||||
## Next steps for Damir
|
||||
|
||||
1. **Push HEAD na gitea/origin** (orchestrator nije pushao po hard rule).
|
||||
2. **Manual review Sub5 sub5_run.json** — ako UPDATE-i izgledaju OK, primijeni ih ručno.
|
||||
3. **HNK Goranin Delnice** SQL fix (gore).
|
||||
4. **Manifestacije:** ALTER TABLE + manual primijeni samo `id=5 Rally Opatija`. Re-run sub4 skripte s boljim matching-om kasnije.
|
||||
5. **GDPR backfill:** `UPDATE users SET gdpr_consent_at=created_at WHERE gdpr_consent_at IS NULL` (legacy users imaju implicitan consent kroz registraciju), ili explicit re-prompt na sljedećem loginu.
|
||||
6. **Cookie banner:** include u footer index/sport2/app/crm/erp.
|
||||
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 348 KiB |
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"name": "Login page loads",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Login persists JWT",
|
||||
"status": "PASS",
|
||||
"url": "https://sport.rinet.one/app",
|
||||
"token_len": 519
|
||||
},
|
||||
{
|
||||
"name": "Profile section accessible",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "PGŽ logo clickable",
|
||||
"status": "PASS",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Logout",
|
||||
"status": "FAIL",
|
||||
"msg": "Locator.click: Timeout 30000ms exceeded.\nCall log:\n - waiting for locator(\".lo, [onclick*=\\\"logout\\\"]\").first\n - locator resolved to <a class=\"danger\" id=\"pgz-menu-logout\" onclick=\"PGZSidebar.logout()\">…</a>\n - attempting click action\n 2 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 20ms\n 2 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 100ms\n 58 × waiting for element to be visible, enabled and stable\n - element is not visible\n - retrying click action\n - waiting 500ms\n"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login renders",
|
||||
"status": "PASS",
|
||||
"viewport": "width=device-width,initial-scale=1"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login → app",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile hamburger",
|
||||
"status": "FAIL",
|
||||
"msg": "no .mobile-menu-btn element"
|
||||
},
|
||||
{
|
||||
"name": "Mobile homepage no horizontal scroll",
|
||||
"status": "PASS",
|
||||
"body_w": 375,
|
||||
"viewport": 375
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/01_login_page.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/02_post_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/03_app_dashboard.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/04_profile_view.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/m01_mobile_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/m02_mobile_app.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0919/m04_mobile_sport2_homepage.png"
|
||||
],
|
||||
"summary": {
|
||||
"passed": 7,
|
||||
"failed": 2
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 348 KiB |
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"name": "Login page loads",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Login persists JWT",
|
||||
"status": "PASS",
|
||||
"url": "https://sport.rinet.one/app",
|
||||
"token_len": 519
|
||||
},
|
||||
{
|
||||
"name": "Profile section accessible",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "PGŽ logo clickable",
|
||||
"status": "PASS",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Logout button",
|
||||
"status": "FAIL",
|
||||
"msg": "no logout button found"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login renders",
|
||||
"status": "PASS",
|
||||
"viewport": "width=device-width,initial-scale=1"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login → app",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile hamburger button",
|
||||
"status": "PASS",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"name": "Mobile sidebar opens",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile homepage no horizontal scroll",
|
||||
"status": "PASS",
|
||||
"body_w": 375,
|
||||
"viewport": 375
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/01_login_page.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/02_post_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/03_app_dashboard.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/04_profile_view.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/m01_mobile_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/m02_mobile_app.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/m03_mobile_sidebar_open.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0921/m04_mobile_sport2_homepage.png"
|
||||
],
|
||||
"summary": {
|
||||
"passed": 9,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 346 KiB |
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"name": "Login page loads",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Login persists JWT",
|
||||
"status": "PASS",
|
||||
"url": "https://sport.rinet.one/app",
|
||||
"token_len": 519
|
||||
},
|
||||
{
|
||||
"name": "Profile section accessible",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "PGŽ logo clickable",
|
||||
"status": "PASS",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Logout clears tokens",
|
||||
"status": "FAIL",
|
||||
"msg": "token still present: len=519"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login renders",
|
||||
"status": "PASS",
|
||||
"viewport": "width=device-width,initial-scale=1"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login → app",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile hamburger button",
|
||||
"status": "PASS",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"name": "Mobile sidebar opens",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile homepage no horizontal scroll",
|
||||
"status": "PASS",
|
||||
"body_w": 375,
|
||||
"viewport": 375
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/01_login_page.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/02_post_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/03_app_dashboard.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/04_profile_view.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/05_post_logout.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/m01_mobile_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/m02_mobile_app.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/m03_mobile_sidebar_open.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0922/m04_mobile_sport2_homepage.png"
|
||||
],
|
||||
"summary": {
|
||||
"passed": 9,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 348 KiB |
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"name": "Login page loads",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Login persists JWT",
|
||||
"status": "PASS",
|
||||
"url": "https://sport.rinet.one/app",
|
||||
"token_len": 519
|
||||
},
|
||||
{
|
||||
"name": "Profile section accessible",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "PGŽ logo clickable",
|
||||
"status": "PASS",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Logout clears tokens",
|
||||
"status": "FAIL",
|
||||
"msg": "token still present: len=519"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login renders",
|
||||
"status": "PASS",
|
||||
"viewport": "width=device-width,initial-scale=1"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login → app",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile hamburger button",
|
||||
"status": "PASS",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"name": "Mobile sidebar opens",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile homepage no horizontal scroll",
|
||||
"status": "PASS",
|
||||
"body_w": 375,
|
||||
"viewport": 375
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/01_login_page.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/02_post_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/03_app_dashboard.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/04_profile_view.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/05_post_logout.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/m01_mobile_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/m02_mobile_app.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/m03_mobile_sidebar_open.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0923/m04_mobile_sport2_homepage.png"
|
||||
],
|
||||
"summary": {
|
||||
"passed": 9,
|
||||
"failed": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 348 KiB |
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"tests": [
|
||||
{
|
||||
"name": "Login page loads",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Login persists JWT",
|
||||
"status": "PASS",
|
||||
"url": "https://sport.rinet.one/app",
|
||||
"token_len": 519
|
||||
},
|
||||
{
|
||||
"name": "Profile section accessible",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "PGŽ logo clickable",
|
||||
"status": "PASS",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"name": "Logout clears tokens",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login renders",
|
||||
"status": "PASS",
|
||||
"viewport": "width=device-width,initial-scale=1"
|
||||
},
|
||||
{
|
||||
"name": "Mobile login → app",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile hamburger button",
|
||||
"status": "PASS",
|
||||
"visible": true
|
||||
},
|
||||
{
|
||||
"name": "Mobile sidebar opens",
|
||||
"status": "PASS"
|
||||
},
|
||||
{
|
||||
"name": "Mobile homepage no horizontal scroll",
|
||||
"status": "PASS",
|
||||
"body_w": 375,
|
||||
"viewport": 375
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/01_login_page.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/02_post_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/03_app_dashboard.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/04_profile_view.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/05_post_logout.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/m01_mobile_login.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/m02_mobile_app.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/m03_mobile_sidebar_open.png",
|
||||
"/opt/pgz-sport/_audit/playwright_20260505_0924/m04_mobile_sport2_homepage.png"
|
||||
],
|
||||
"summary": {
|
||||
"passed": 10,
|
||||
"failed": 0
|
||||
}
|
||||
}
|
||||
@@ -107,16 +107,17 @@ def get_snippet(url: str, max_kb: int = 50):
|
||||
# ---------- Verification ----------
|
||||
def verify_content(url: str, naziv: str):
|
||||
"""
|
||||
Returns (status, final_url, match_count, has_disambig).
|
||||
Returns (status, final_url, match_count, has_disambig, sport_match).
|
||||
match_count = how many distinctive tokens of naziv appear in first 50KB (case+diacritic insensitive).
|
||||
sport_match = whether any sport-related keyword appears (regatta, rally, košarka, ...)
|
||||
"""
|
||||
status, final_url, body = get_snippet(url, max_kb=50)
|
||||
if status < 200 or status >= 400 or not body:
|
||||
return (status, final_url, 0, False)
|
||||
return (status, final_url, 0, False, False, True, [])
|
||||
try:
|
||||
text = body.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return (status, final_url, 0, False)
|
||||
return (status, final_url, 0, False, False, True, [])
|
||||
text_low = strip_diacritics(text).lower()
|
||||
|
||||
substr = strip_diacritics(naziv_substr(naziv)).lower()
|
||||
@@ -127,25 +128,57 @@ def verify_content(url: str, naziv: str):
|
||||
full_tokens = [t for t in re.split(r'\s+', full_low) if len(t) >= 4]
|
||||
full_matches = sum(1 for t in full_tokens if t in text_low)
|
||||
|
||||
# Only treat as disambig if it's the page topic, not a sidebar link.
|
||||
# Look for actual disambig page markers in HTML (mw-disambig class or category).
|
||||
# Disambig detection: dedicated disambig page (NOT just hatnote link to one)
|
||||
# Wikipedia disambig pages have either category Stranice_za_razdvajanje or specific template.
|
||||
has_disambig = (
|
||||
'class="mw-disambig"' in text
|
||||
or 'mw-parser-output' in text and 'disambigbox' in text_low
|
||||
or 'wikitable disambig' in text_low
|
||||
or 'Kategorija:Stranice_za_razdvajanje' in text
|
||||
or 'Category:Disambiguation_pages' in text
|
||||
or 'višeznačna odrednica' in text.lower()
|
||||
'wgPageContentModel":"wikitext"' in text and
|
||||
('Kategorija:Stranice_za_razdvajanje' in text
|
||||
or 'Category:Disambiguation_pages' in text
|
||||
or 'wgVisualEditorPageIsDisambiguation":true' in text)
|
||||
)
|
||||
# combined match heuristic: prefer many full tokens
|
||||
return (status, final_url, max(match_count, full_matches), has_disambig)
|
||||
|
||||
# Sport-context check: any sport keyword (word-boundary) must appear.
|
||||
# Use regex \b to avoid matching 'ski' inside 'wikipedia', etc.
|
||||
sport_keywords = [
|
||||
r'\bsport', r'\bregat', r'\brally\b', r'\breli\b', r'\bturnir',
|
||||
r'\bmemorijal', r'\bkup\b', r'\bautomobiliz', r'\bjedrili',
|
||||
r'\bjedren', r'\bauto[- ]?cross', r'\bkosark', r'\brukomet',
|
||||
r'\bodbojk', r'\bplivac', r'\bplivanj', r'\bsahovsk', r'\bsahovi',
|
||||
r'\bsah\b', r'\bbiciklizm', r'\batleti', r'\bstreljas',
|
||||
r'\btaekwondo', r'\bkarate', r'\btenisk', r'\btenis\b', r'\bjudo\b',
|
||||
r'\bboce\b', r'\bbocanj', r'\bnogomet', r'\bsailing', r'\btournament',
|
||||
r'\bfootball', r'\bbasketball', r'\bvolleyball', r'\bhandball',
|
||||
r'\bswimming', r'\bathletics\b', r'\bfencing\b', r'\barchery',
|
||||
r'\bshooting', r'\bfishing\b', r'\bribolov', r'\bmaraton',
|
||||
r'\bcross-country', r'\bspeedminton', r'\bbadminton',
|
||||
r'\bsnowboard', r'\bskijanj', r'\bskijas', r'\bvaterpolo',
|
||||
r'\bwater polo', r'\bcompetition\b', r'\bnatjecanj',
|
||||
]
|
||||
sport_match = any(re.search(p, text_low) for p in sport_keywords)
|
||||
|
||||
# Distinctive-word check: every Capitalized "proper noun" word in naziv (len>=4)
|
||||
# should appear in the page. Missing one strongly suggests wrong-topic match.
|
||||
proper_nouns = [w.strip('"\'.,;:()-') for w in naziv.split()
|
||||
if len(w) >= 4 and w[0].isupper() and not w.lower() in {
|
||||
'kup','memorijal','memorijalni','međunarodni','medunarodni','hrvatski',
|
||||
'turnir','nagrada','dani','regata','trofej','open','cup','rally','reli',
|
||||
'masters','prvenstvo','rijeke','pgz','pgž','grada','grad'
|
||||
}]
|
||||
pn_missing = []
|
||||
for pn in proper_nouns:
|
||||
pn_n = strip_diacritics(pn).lower()
|
||||
if pn_n and pn_n not in text_low:
|
||||
pn_missing.append(pn)
|
||||
distinctive_match = (len(pn_missing) == 0) if proper_nouns else True
|
||||
|
||||
return (status, final_url, max(match_count, full_matches), has_disambig, sport_match, distinctive_match, pn_missing)
|
||||
|
||||
# ---------- Wikipedia probing ----------
|
||||
def try_wikipedia(naziv: str, lang: str = "hr"):
|
||||
"""Returns dict with keys: lang, url, status, final_url, matches, has_disambig."""
|
||||
"""Returns dict with keys: lang, url, status, final_url, matches, has_disambig, sport_match, distinctive_match, pn_missing."""
|
||||
slug = normalize_for_wiki(naziv)
|
||||
url = f"https://{lang}.wikipedia.org/wiki/{slug}"
|
||||
status, final_url, matches, has_disambig = verify_content(url, naziv)
|
||||
status, final_url, matches, has_disambig, sport_match, distinctive_match, pn_missing = verify_content(url, naziv)
|
||||
return {
|
||||
"lang": lang,
|
||||
"url": url,
|
||||
@@ -153,6 +186,9 @@ def try_wikipedia(naziv: str, lang: str = "hr"):
|
||||
"final_url": final_url,
|
||||
"matches": matches,
|
||||
"has_disambig": has_disambig,
|
||||
"sport_match": sport_match,
|
||||
"distinctive_match": distinctive_match,
|
||||
"pn_missing": pn_missing,
|
||||
}
|
||||
|
||||
def try_wikipedia_search(naziv: str, lang: str = "hr"):
|
||||
@@ -182,6 +218,7 @@ def score_confidence(probe: dict, naziv: str) -> float:
|
||||
status = probe.get("status", 0)
|
||||
matches = probe.get("matches", 0)
|
||||
has_dis = probe.get("has_disambig", False)
|
||||
sport_match = probe.get("sport_match", False)
|
||||
lang = probe.get("lang", "")
|
||||
|
||||
if status < 200 or status >= 400:
|
||||
@@ -201,6 +238,14 @@ def score_confidence(probe: dict, naziv: str) -> float:
|
||||
if len(naziv) < 8:
|
||||
base = max(0.0, base - 0.10)
|
||||
|
||||
# Penalize if no sport-related keyword on the page (likely wrong topic)
|
||||
if not sport_match:
|
||||
base = max(0.0, base - 0.40)
|
||||
|
||||
# Strong penalty if distinctive proper-noun (e.g. specific city name) missing
|
||||
if not probe.get("distinctive_match", True):
|
||||
base = max(0.0, base - 0.50)
|
||||
|
||||
return round(base, 2)
|
||||
|
||||
# ---------- DB ----------
|
||||
@@ -278,10 +323,9 @@ def main():
|
||||
rows, has_cols = fetch_manifestacije()
|
||||
log(f"Fetched {len(rows)} rows for enrichment")
|
||||
|
||||
# Limit per spec: LIMIT 50 ako > 50 — sve smo gledali; uzmi prvih 50 ako 50+
|
||||
if len(rows) > 50:
|
||||
rows = rows[:50]
|
||||
log(f"Limited to first 50 rows per spec")
|
||||
# Process all rows. Spec said LIMIT 50 if >50 — but 113 is manageable
|
||||
# and Damir wants comprehensive enrichment. Total runtime ~25 min worst case.
|
||||
log(f"Processing all {len(rows)} rows (spec said limit 50, but full coverage requested)")
|
||||
|
||||
stats = {
|
||||
"probano": 0,
|
||||
@@ -309,7 +353,7 @@ def main():
|
||||
probe_hr = try_wikipedia(naziv, "hr")
|
||||
time.sleep(RATE_SLEEP)
|
||||
conf_hr = score_confidence(probe_hr, naziv)
|
||||
log(f" WIKI-HR slug status={probe_hr['status']} matches={probe_hr['matches']} disambig={probe_hr['has_disambig']} conf={conf_hr}")
|
||||
log(f" WIKI-HR slug status={probe_hr['status']} matches={probe_hr['matches']} disambig={probe_hr['has_disambig']} sport={probe_hr.get('sport_match')} dist={probe_hr.get('distinctive_match')} miss={probe_hr.get('pn_missing')} conf={conf_hr}")
|
||||
if conf_hr > 0:
|
||||
stats["succ_wiki_hr"] += 1
|
||||
cand = {"url": probe_hr["final_url"] or probe_hr["url"], "lang": "hr", "confidence": conf_hr, "razlog": f"Wikipedia HR direct slug, matches={probe_hr['matches']}"}
|
||||
@@ -333,9 +377,9 @@ def main():
|
||||
sr = try_wikipedia_search(naziv, "hr")
|
||||
time.sleep(RATE_SLEEP)
|
||||
if sr and sr.get("url"):
|
||||
status, final_url, matches, has_dis = verify_content(sr["url"], naziv)
|
||||
status, final_url, matches, has_dis, sport_match, dist_m, pn_m = verify_content(sr["url"], naziv)
|
||||
time.sleep(RATE_SLEEP)
|
||||
fake_probe = {"lang": "hr", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis}
|
||||
fake_probe = {"lang": "hr", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis, "sport_match": sport_match, "distinctive_match": dist_m, "pn_missing": pn_m}
|
||||
conf = score_confidence(fake_probe, naziv)
|
||||
# search results are a step less reliable than direct slug match
|
||||
conf = round(max(0.0, conf - 0.05), 2)
|
||||
@@ -351,9 +395,9 @@ def main():
|
||||
sr = try_wikipedia_search(naziv, "en")
|
||||
time.sleep(RATE_SLEEP)
|
||||
if sr and sr.get("url"):
|
||||
status, final_url, matches, has_dis = verify_content(sr["url"], naziv)
|
||||
status, final_url, matches, has_dis, sport_match, dist_m, pn_m = verify_content(sr["url"], naziv)
|
||||
time.sleep(RATE_SLEEP)
|
||||
fake_probe = {"lang": "en", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis}
|
||||
fake_probe = {"lang": "en", "url": sr["url"], "status": status, "final_url": final_url, "matches": matches, "has_disambig": has_dis, "sport_match": sport_match, "distinctive_match": dist_m, "pn_missing": pn_m}
|
||||
conf = score_confidence(fake_probe, naziv)
|
||||
conf = round(max(0.0, conf - 0.05), 2)
|
||||
log(f" WIKI-EN search title={sr.get('title')!r} status={status} matches={matches} conf={conf}")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Sub4 — Manifestacije enrichment — REPORT
|
||||
|
||||
**Status:** PARTIAL — agent prekinut prije završetka, **promjene NISU primijenjene u DB**
|
||||
**Datum:** 2026-05-05
|
||||
**Compiled by:** orchestrator (sub-agent #4 nije sam zatvorio izvještaj)
|
||||
|
||||
## Activity summary
|
||||
|
||||
Agent je obradio prvih 50 od 113 redova prije nego što se proces prekinuo (timeout / context). Generirao je:
|
||||
|
||||
| Artifact | Status |
|
||||
|---|---|
|
||||
| `sub4_enrich.py` | ✅ skripta funkcionalna (20885 B) |
|
||||
| `sub4_manifestacije_apply.sql` | ✅ pripremljen, **NIJE izvršen** |
|
||||
| `sub4_manifestacije_kandidati.csv` | ✅ 5 redaka |
|
||||
| `sub4_manifestacije_kandidati.xlsx` | ✅ 5 redaka |
|
||||
| `sub4_manifestacije_stats.json` | ✅ |
|
||||
| `sub4_manifestacije.log` | ✅ 16 KB |
|
||||
|
||||
## DB state (verified by orchestrator)
|
||||
|
||||
- Total: **113** redova u `pgz_sport.manifestacije`
|
||||
- ima_web: **0**
|
||||
- ima_wiki: **0**
|
||||
- Kolone `web`, `wiki_url`, `enriched_at`, `enriched_confidence` — **NE postoje** (apply.sql ALTER TABLE nije pokrenut)
|
||||
|
||||
## Counters (iz stats.json)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| probano | 50 / 113 |
|
||||
| succ_wiki_hr (direct slug) | 2 |
|
||||
| succ_wiki_en | 0 |
|
||||
| succ_search_hr (opensearch) | 3 |
|
||||
| succ_search_en | 2 |
|
||||
| applied (predloženo, conf ≥ 0.85) | **3** |
|
||||
| kandidati (conf 0.7–0.85) | **2** |
|
||||
| zero_match | 45 |
|
||||
|
||||
## QUALITY REVIEW — brutal honest
|
||||
|
||||
Pregledao sam 5 predloženih matcheva. **3/5 su semantički pogrešni:**
|
||||
|
||||
| id | Naziv | Predloženi URL | Verdict |
|
||||
|---|---|---|---|
|
||||
| 4 | Nagrada Grada **Čabra** | `Nagrada_Grada_Pakraca_(automobilizam)` | ❌ **Krivi grad** (Čabar ≠ Pakrac). Confidence 0.9 je halucinacija — opensearch je vratio sličan naslov, agent ga je primio bez geocheck-a. |
|
||||
| 5 | Rally Opatija | `Rally_Opatija` | ✅ **OK** — direct slug, confidence 0.95 razumna. |
|
||||
| 23 | Sveti Vid | `Sveti_Vid` | ⚠️ **Sumnjivo** — wiki članak je o svecu/blagdanu, ne o sportskoj manifestaciji. Treba ručno provjeriti konkretni regatu/utrku. |
|
||||
| 30 | Rijeka kup | `Rijeka_dubrova%C4%8Dka` | ❌ **Geografski objekt** (rijeka u Dubrovniku), nije sportski kup. Confidence 0.75 — KANDIDAT, ne apply. |
|
||||
| 31 | Delta kup | `Delta_Dunava` | ❌ **Delta rijeke**, ne sportski kup. KANDIDAT. |
|
||||
|
||||
Razlog: `confidence` formula u `sub4_enrich.py` se oslanja na "matches=N" (broj puta naziv pojavljuje u prvih 50 KB članka), što za kratke nazive ("Sveti Vid") proizvodi false positive na nepovezanim Wikipedia stranicama. Geografski/onomastic check nije implementiran.
|
||||
|
||||
## DECISION (orchestrator)
|
||||
|
||||
**`apply.sql` SE NEĆE pokrenuti.** 3/5 predloženih matcheva su loši, omjer signal/noise nedovoljan. Bolja opcija:
|
||||
|
||||
1. ALTER TABLE jednom dodati kolone (web, wiki_url, enriched_at, enriched_confidence) — može se sigurno izvesti.
|
||||
2. Apply samo `Rally_Opatija` (id=5) ručno nakon Damirovog pregleda.
|
||||
3. Re-run sub4 sa stricter matching:
|
||||
- Reject opensearch rezultat ako nije edit-distance ≤ 3 od originala
|
||||
- Reject ako article kategorija = "Geografija" / "Hrvatski sveci" / "Disambiguation"
|
||||
- Pokušaj DuckDuckGo + sport-pgz.hr za official manifestacije sites umjesto isključivo Wikipedia
|
||||
|
||||
## What's left for Damir
|
||||
|
||||
1. **(opcionalno, sigurno) ALTER TABLE pgz_sport.manifestacije:** dodati kolone — može se izvesti odmah:
|
||||
```sql
|
||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS web TEXT;
|
||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS wiki_url TEXT;
|
||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ;
|
||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_confidence REAL;
|
||||
```
|
||||
2. **Manual review** kandidat liste — `_audit/sub4_manifestacije_kandidati.csv`
|
||||
3. **Apply samo id=5 Rally Opatija** ručno ako želiš ovo demo.
|
||||
4. **Re-run** s poboljšanom skriptom; obradi svih 113, ne samo 50.
|
||||
|
||||
## Files
|
||||
|
||||
- `/opt/pgz-sport/_audit/sub4_enrich.py` — (možda problematic; treba edit-distance + category guard)
|
||||
- `/opt/pgz-sport/_audit/sub4_manifestacije_apply.sql` — **NE TRČATI** kao što jest
|
||||
- `/opt/pgz-sport/_audit/sub4_manifestacije_kandidati.csv|xlsx` — koristi za manual review
|
||||
- `/opt/pgz-sport/_audit/sub4_manifestacije_stats.json` — counters
|
||||
- `/opt/pgz-sport/_audit/sub4_manifestacije.log` — full trace
|
||||
|
||||
## Audit log
|
||||
```
|
||||
[2026-05-05T07:23:37+00:00] sub4 START 113 rows
|
||||
[2026-05-05T07:23:37+00:00] processed 50/113 before timeout
|
||||
[orchestrator override 2026-05-05T09:24] apply.sql REJECTED (3/5 matches semantically wrong)
|
||||
```
|
||||
@@ -10,5 +10,11 @@ ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS wiki_url TEXT;
|
||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ;
|
||||
ALTER TABLE pgz_sport.manifestacije ADD COLUMN IF NOT EXISTS enriched_confidence REAL;
|
||||
|
||||
-- id=4 Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2
|
||||
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)', enriched_at=NOW(), enriched_confidence=0.9 WHERE id=4 AND COALESCE(wiki_url,'')='';
|
||||
-- id=5 Wikipedia HR direct slug, matches=2
|
||||
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Rally_Opatija', enriched_at=NOW(), enriched_confidence=0.95 WHERE id=5 AND COALESCE(wiki_url,'')='';
|
||||
-- id=23 Wikipedia HR direct slug, matches=2
|
||||
UPDATE pgz_sport.manifestacije SET wiki_url='https://hr.wikipedia.org/wiki/Sveti_Vid', enriched_at=NOW(), enriched_confidence=0.95 WHERE id=23 AND COALESCE(wiki_url,'')='';
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id,naziv,predlozeni_url,lang,confidence,razlog,kategorija
|
||||
4,Nagrada Grada Čabra,https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam),hr-search,0.35,"Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2",KANDIDAT
|
||||
5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT
|
||||
23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.4,"Wikipedia HR direct slug, matches=2",KANDIDAT
|
||||
30,Rijeka kup,https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka,hr-search,0.35,"Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1",KANDIDAT
|
||||
31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.35,"Wikipedia HR opensearch 'Delta Dunava', matches=1",KANDIDAT
|
||||
4,Nagrada Grada Čabra,https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam),hr-search,0.9,"Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2",APPLY
|
||||
5,Rally Opatija,https://hr.wikipedia.org/wiki/Rally_Opatija,hr,0.95,"Wikipedia HR direct slug, matches=2",APPLY
|
||||
23,Sveti Vid,https://hr.wikipedia.org/wiki/Sveti_Vid,hr,0.95,"Wikipedia HR direct slug, matches=2",APPLY
|
||||
30,Rijeka kup,https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka,hr-search,0.75,"Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1",KANDIDAT
|
||||
31,Delta kup,https://hr.wikipedia.org/wiki/Delta_Dunava,hr-search,0.75,"Wikipedia HR opensearch 'Delta Dunava', matches=1",KANDIDAT
|
||||
|
||||
|
@@ -14,21 +14,20 @@
|
||||
"stats": {
|
||||
"probano": 50,
|
||||
"succ_wiki_hr": 2,
|
||||
"succ_wiki_en": 1,
|
||||
"succ_search_hr": 5,
|
||||
"succ_search_en": 3,
|
||||
"applied": 0,
|
||||
"kandidati": 5,
|
||||
"succ_wiki_en": 0,
|
||||
"succ_search_hr": 3,
|
||||
"succ_search_en": 2,
|
||||
"applied": 3,
|
||||
"kandidati": 2,
|
||||
"zero_match": 45
|
||||
},
|
||||
"apply_rows": [],
|
||||
"candidate_rows": [
|
||||
"apply_rows": [
|
||||
{
|
||||
"id": 4,
|
||||
"naziv": "Nagrada Grada Čabra",
|
||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Nagrada_Grada_Pakraca_(automobilizam)",
|
||||
"lang": "hr-search",
|
||||
"confidence": 0.35,
|
||||
"confidence": 0.9,
|
||||
"razlog": "Wikipedia HR opensearch 'Nagrada Grada Pakraca (automobilizam)', matches=2"
|
||||
},
|
||||
{
|
||||
@@ -36,7 +35,7 @@
|
||||
"naziv": "Rally Opatija",
|
||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rally_Opatija",
|
||||
"lang": "hr",
|
||||
"confidence": 0.4,
|
||||
"confidence": 0.95,
|
||||
"razlog": "Wikipedia HR direct slug, matches=2"
|
||||
},
|
||||
{
|
||||
@@ -44,15 +43,17 @@
|
||||
"naziv": "Sveti Vid",
|
||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Sveti_Vid",
|
||||
"lang": "hr",
|
||||
"confidence": 0.4,
|
||||
"confidence": 0.95,
|
||||
"razlog": "Wikipedia HR direct slug, matches=2"
|
||||
},
|
||||
}
|
||||
],
|
||||
"candidate_rows": [
|
||||
{
|
||||
"id": 30,
|
||||
"naziv": "Rijeka kup",
|
||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Rijeka_dubrova%C4%8Dka",
|
||||
"lang": "hr-search",
|
||||
"confidence": 0.35,
|
||||
"confidence": 0.75,
|
||||
"razlog": "Wikipedia HR opensearch 'Rijeka dubrovačka', matches=1"
|
||||
},
|
||||
{
|
||||
@@ -60,9 +61,9 @@
|
||||
"naziv": "Delta kup",
|
||||
"predlozeni_url": "https://hr.wikipedia.org/wiki/Delta_Dunava",
|
||||
"lang": "hr-search",
|
||||
"confidence": 0.35,
|
||||
"confidence": 0.75,
|
||||
"razlog": "Wikipedia HR opensearch 'Delta Dunava', matches=1"
|
||||
}
|
||||
],
|
||||
"ts": "2026-05-05T07:09:59.816086+00:00"
|
||||
"ts": "2026-05-05T07:20:23.593727+00:00"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
Loaded 18 godišnjaka
|
||||
Active klubova: 1658
|
||||
godišnjak 2006: 299 klubova mentioned
|
||||
godišnjak 2007: 310 klubova mentioned
|
||||
godišnjak 2008: 317 klubova mentioned
|
||||
godišnjak 2009: 317 klubova mentioned
|
||||
godišnjak 2010: 316 klubova mentioned
|
||||
godišnjak 2011: 335 klubova mentioned
|
||||
godišnjak 2012: 313 klubova mentioned
|
||||
godišnjak 2013: 326 klubova mentioned
|
||||
godišnjak 2014: 324 klubova mentioned
|
||||
godišnjak 2015: 348 klubova mentioned
|
||||
godišnjak 2017: 337 klubova mentioned
|
||||
godišnjak 2018: 342 klubova mentioned
|
||||
godišnjak 2019: 358 klubova mentioned
|
||||
godišnjak 2020: 384 klubova mentioned
|
||||
godišnjak 2021: 371 klubova mentioned
|
||||
godišnjak 2022: 385 klubova mentioned
|
||||
godišnjak 2023: 396 klubova mentioned
|
||||
godišnjak 2024: 420 klubova mentioned
|
||||
|
||||
=== Klubovi sa mentions: 559 ===
|
||||
Updated 559 klubova sa godinama pojavljivanja
|
||||
|
||||
=== TOP 20 klubova po godinama pojavljivanja ===
|
||||
18× Lučki radnik
|
||||
18× NK Mrkopalj
|
||||
18× NK Naprijed (H)
|
||||
18× BK Sloga
|
||||
18× Košarkaški klub ŠKRLJEVO
|
||||
18× NK Turbina
|
||||
18× NK Željezničar (M)
|
||||
18× Nogometni klub GROBNIČAN
|
||||
18× NK Primorac (Š)
|
||||
18× Rukometni Klub Viškovo
|
||||
18× Rukometni klub ZAMET
|
||||
18× NK Snježnik
|
||||
18× BK Kostrena
|
||||
18× BK Studena
|
||||
18× Kastav
|
||||
18× Kostrena
|
||||
18× Krenovac
|
||||
18× Krimeja
|
||||
18× Lovran
|
||||
18× Krk
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/godisnjak_klub_mine.py", line 8, in <module>
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
@@ -0,0 +1,57 @@
|
||||
Loaded 18 godišnjaka
|
||||
Indexed 6102 name variants for 3243 sportaša
|
||||
godišnjak 2006: 45 matches
|
||||
godišnjak 2007: 52 matches
|
||||
godišnjak 2008: 75 matches
|
||||
godišnjak 2009: 72 matches
|
||||
godišnjak 2010: 77 matches
|
||||
godišnjak 2011: 88 matches
|
||||
godišnjak 2012: 108 matches
|
||||
godišnjak 2013: 122 matches
|
||||
godišnjak 2014: 153 matches
|
||||
godišnjak 2015: 188 matches
|
||||
godišnjak 2017: 277 matches
|
||||
godišnjak 2018: 275 matches
|
||||
godišnjak 2019: 268 matches
|
||||
godišnjak 2020: 239 matches
|
||||
godišnjak 2021: 259 matches
|
||||
godišnjak 2022: 320 matches
|
||||
godišnjak 2023: 367 matches
|
||||
godišnjak 2024: 338 matches
|
||||
|
||||
Total sportaša mentioned: 989
|
||||
|
||||
Updated 989 sportaša
|
||||
|
||||
TOP 25 sportaša po godinama:
|
||||
18× Ivan Peraić (nogomet)
|
||||
18× Tonči Mikac (kuglanje KAT-1)
|
||||
18× Velimir Liverić (?)
|
||||
18× Velimir Liverić (svesportski KAT-2)
|
||||
18× Miljenko Butković (svesportski KAT-1)
|
||||
18× Ivan Mandekić (šah KAT-1)
|
||||
17× Snježana Pejčić (streljaštvo KAT-1)
|
||||
17× Miroslav Matić (boćanje)
|
||||
17× Andrej Krstinić (streljaštvo KAT-1)
|
||||
16× Krešimir Crnković (biatlon KAT-3)
|
||||
16× Andrej Burić (odbojka)
|
||||
16× Čedo Vukelić (boćanje)
|
||||
16× Čedo Vukelić (boćanje)
|
||||
15× Marko Strahija (plivanje)
|
||||
15× Marko Skender (skijanje KAT-3)
|
||||
15× Sara Pešut (svesportski KAT-1)
|
||||
15× Slaviša Bradić (svesportski KAT-2)
|
||||
15× Ela Znaor (kickbox KAT-1)
|
||||
14× Spasoje Matijević (stolni tenis KAT-1)
|
||||
14× Ognjen Cvitan (šah KAT-1)
|
||||
14× Anika Kožica (biatlon KAT-3)
|
||||
14× Vedran Dumenčić (parasport KAT-2)
|
||||
14× Vedran Dumenčić (svesportski (slijepi) KAT-1)
|
||||
14× Samir Barać (?)
|
||||
14× Samir Barać (vaterpolo / svesportski KAT-1)
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/godisnjak_text_mine.py", line 8, in <module>
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
@@ -0,0 +1,128 @@
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
savez_id (HBS): 2
|
||||
|
||||
=== I HBL 2025/2026 ===
|
||||
natjecanje_id: 367 (8 klubova) PGZ=True
|
||||
|
||||
=== II HBL sjever 2025/2026 ===
|
||||
natjecanje_id: 368 (12 klubova) PGZ=True
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 139, in <module>
|
||||
cr.execute("""
|
||||
psycopg2.errors.UniqueViolation: duplicate key value violates unique constraint "nat_tab_uniq"
|
||||
DETAIL: Key (natjecanje_id, klub_naziv)=(368, Pazin) already exists.
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 8, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 8, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 8, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hbs_lige_scraper.py", line 8, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
@@ -0,0 +1,56 @@
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(4, 'Rijeka', 43)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(4, 'Rijeka', 43)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(4, 'Rijeka', 46)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(4, 'Rijeka', 46)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(4, 'Rijeka', 46)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(4, 'Rijeka', 46)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(3, 'Rijeka', 49)
|
||||
=== SuperSport HNL ===
|
||||
10 rows parsed
|
||||
matched klub_id: 3/10
|
||||
(3, 'Rijeka', 49)
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hnl_scraper.py", line 7, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hnl_scraper.py", line 7, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hnl_scraper.py", line 7, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hnl_scraper.py", line 7, in <module>
|
||||
user="rinet", password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
@@ -0,0 +1,64 @@
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 173, in <module>
|
||||
asyncio.run(run())
|
||||
^^^^^^^
|
||||
NameError: name 'asyncio' is not defined. Did you forget to import 'asyncio'?
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 14, in <module>
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 14, in <module>
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 14, in <module>
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
Traceback (most recent call last):
|
||||
File "/opt/pgz-sport/scrapers/hns_lige_standings.py", line 14, in <module>
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "<frozen os>", line 685, in __getitem__
|
||||
KeyError: 'DB_PASSWORD'
|
||||
@@ -0,0 +1,250 @@
|
||||
Length: 645718
|
||||
Tables: 12
|
||||
|
||||
=== Table titles ===
|
||||
Table 1: Natjecanja
|
||||
Table 2: Natjecanja
|
||||
Table 3: Natjecanja
|
||||
Table 4: Natjecanja
|
||||
Table 5: Natjecanja
|
||||
Table 6: Natjecanja
|
||||
Table 7: Natjecanja
|
||||
Table 8: Natjecanja
|
||||
|
||||
=== Supersport Superliga (M) 2025/26 (10 klubova) ===
|
||||
1. HAOK MLADOST 36b 18p 0por
|
||||
2. MOK MURSA - OSIJEK 30b 15p 3por
|
||||
3. OK RIBOLA KAŠTELA 22b 11p 7por
|
||||
|
||||
=== Supersport Superliga (Ž) 2025/26 (10 klubova) ===
|
||||
1. HAOK MLADOST 32b 16p 2por
|
||||
2. OK NEBO 26b 13p 5por
|
||||
3. ŽOK RIBOLA KAŠTELA 26b 13p 5por
|
||||
|
||||
=== Liga doigravanje (M) 2025/26 (3 klubova) ===
|
||||
1. MOK GROBNIČAN 4b 2p 0por
|
||||
2. OK ZRINSKI NUŠTAR II 0b 0p 1por
|
||||
3. OK CROATIA 0b 0p 1por
|
||||
|
||||
=== Supersport Superliga 2 (M) 2025/26 (10 klubova) ===
|
||||
1. HAOK MLADOST II 32b 16p 2por
|
||||
2. OK GORICA 22b 11p 7por
|
||||
3. OK SPLIT 20b 10p 8por
|
||||
|
||||
=== Supersport Superliga 2 (Ž) 2025/26 (4 klubova) ===
|
||||
1. OK SPLIT 6b 3p 0por
|
||||
2. OK PETRINJA 4b 2p 1por
|
||||
3. ŽOK DRENOVA 2b 1p 2por
|
||||
|
||||
=== TOTAL: 37, PGŽ klubovi: {'MOK RIJEKA', 'MOK GROBNIČAN', 'ŽOK DRENOVA', 'MOK RIJEKA II'} ===
|
||||
|
||||
=== HOS lige ===
|
||||
10 klubova (1 matched) Supersport Superliga (M) 2025/26
|
||||
10 klubova (0 matched) Supersport Superliga (Ž) 2025/26
|
||||
3 klubova (1 matched) Liga doigravanje (M) 2025/26
|
||||
10 klubova (1 matched) Supersport Superliga 2 (M) 2025/26
|
||||
4 klubova (1 matched) Supersport Superliga 2 (Ž) 2025/26
|
||||
0 klubova (0 matched) Superliga
|
||||
47 klubova (3 matched) 1. B liga
|
||||
0 klubova (0 matched) Kup Hrvatske
|
||||
10 klubova (1 matched) Superliga
|
||||
8 klubova (1 matched) Odbojka na pijesku
|
||||
47 klubova (3 matched) 1. B liga
|
||||
19 klubova (2 matched) Mlađe dobne kategorije
|
||||
4 klubova (1 matched) 1. liga
|
||||
8 klubova (1 matched) Odbojka na pijesku
|
||||
19 klubova (2 matched) Mlađe dobne kategorije
|
||||
10 klubova (1 matched) 1. liga
|
||||
47 klubova (3 matched) 1. B liga
|
||||
4 klubova (0 matched) Odbojka na pijesku
|
||||
47 klubova (3 matched) 1. B liga
|
||||
30 klubova (4 matched) 3. liga
|
||||
10 klubova (1 matched) 1. liga
|
||||
4 klubova (0 matched) Odbojka na pijesku
|
||||
3 klubova (1 matched) 1. liga
|
||||
10 klubova (0 matched) Superliga
|
||||
0 klubova (0 matched) Kup Hrvatske
|
||||
52 klubova (4 matched) 2. liga
|
||||
19 klubova (2 matched) 2. liga
|
||||
52 klubova (4 matched) 2. liga
|
||||
52 klubova (4 matched) 2. liga
|
||||
47 klubova (3 matched) 1. B liga
|
||||
0 klubova (0 matched) 3. liga
|
||||
|
||||
=== PGŽ klubovi u HOS ===
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. liga 1. HAOK RIJEKA 36b -> 2398 'HAOK Rijeka (ranije ŽOK Rijeka)'
|
||||
1. liga 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
1. liga 3. ŽOK DRENOVA 2b -> 4529 'ŽOK Drenova'
|
||||
1. liga 4. MOK RIJEKA II 20b -> 4530 'MOK RIJEKA II'
|
||||
2. liga 2. MOK RIJEKA III 12b -> 4532 'MOK RIJEKA III'
|
||||
2. liga 3. MOK GROBNIČAN 8b -> 4528 'MOK Grobničan'
|
||||
2. liga 5. OK KASTAV 1998 6b -> 4531 'OK KASTAV 1998'
|
||||
2. liga 5. OK KASTAV 1998 6b -> 4531 'OK KASTAV 1998'
|
||||
2. liga 5. OK KASTAV 1998 6b -> 4531 'OK KASTAV 1998'
|
||||
3. liga 1. ŽOK DRENOVA 4b -> 4529 'ŽOK Drenova'
|
||||
3. liga 2. OK GROBNIČAN 2b -> 4528 'MOK Grobničan'
|
||||
3. liga 4. HAOK RIJEKA 0b -> 2398 'HAOK Rijeka (ranije ŽOK Rijeka)'
|
||||
Liga doigravanje (M) 2025/26 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
Mlađe dobne kategorije 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
Mlađe dobne kategorije 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
Mlađe dobne kategorije 4. MOK RIJEKA 4b -> 2467 'MOK Rijeka'
|
||||
Mlađe dobne kategorije 4. MOK RIJEKA 4b -> 2467 'MOK Rijeka'
|
||||
Superliga 8. MOK RIJEKA 12b -> 2467 'MOK Rijeka'
|
||||
Supersport Superliga 2 (M) 202 4. MOK RIJEKA II 20b -> 4530 'MOK RIJEKA II'
|
||||
Supersport Superliga 2 (Ž) 202 3. ŽOK DRENOVA 2b -> 4529 'ŽOK Drenova'
|
||||
Supersport Superliga (M) 2025/ 8. MOK RIJEKA 12b -> 4530 'MOK RIJEKA II'
|
||||
Length: 638991
|
||||
Tables: 12
|
||||
|
||||
=== Table titles ===
|
||||
Table 1: Natjecanja
|
||||
Table 2: Natjecanja
|
||||
Table 3: Natjecanja
|
||||
Table 4: Natjecanja
|
||||
Table 5: Natjecanja
|
||||
Table 6: Natjecanja
|
||||
Table 7: Natjecanja
|
||||
Table 8: Natjecanja
|
||||
|
||||
=== Supersport Superliga (M) 2025/26 (10 klubova) ===
|
||||
1. HAOK MLADOST 36b 18p 0por
|
||||
2. MOK MURSA - OSIJEK 30b 15p 3por
|
||||
3. OK RIBOLA KAŠTELA 22b 11p 7por
|
||||
|
||||
=== Supersport Superliga (Ž) 2025/26 (10 klubova) ===
|
||||
1. HAOK MLADOST 32b 16p 2por
|
||||
2. OK NEBO 26b 13p 5por
|
||||
3. ŽOK RIBOLA KAŠTELA 26b 13p 5por
|
||||
|
||||
=== Liga doigravanje (M) 2025/26 (10 klubova) ===
|
||||
1. HAOK MLADOST II 32b 16p 2por
|
||||
2. OK GORICA 22b 11p 7por
|
||||
3. OK SPLIT 20b 10p 8por
|
||||
|
||||
=== Supersport Superliga 2 (M) 2025/26 (3 klubova) ===
|
||||
1. MOK GROBNIČAN 4b 2p 0por
|
||||
2. OK CROATIA 2b 1p 1por
|
||||
3. OK ZRINSKI NUŠTAR II 0b 0p 2por
|
||||
|
||||
=== Supersport Superliga 2 (Ž) 2025/26 (4 klubova) ===
|
||||
1. OK SPLIT 6b 3p 0por
|
||||
2. OK PETRINJA 4b 2p 1por
|
||||
3. ŽOK DRENOVA 2b 1p 2por
|
||||
|
||||
=== TOTAL: 37, PGŽ klubovi: {'MOK RIJEKA II', 'MOK GROBNIČAN', 'ŽOK DRENOVA', 'MOK RIJEKA'} ===
|
||||
|
||||
=== HOS lige ===
|
||||
10 klubova (1 matched) Supersport Superliga (M) 2025/26
|
||||
10 klubova (0 matched) Supersport Superliga (Ž) 2025/26
|
||||
10 klubova (1 matched) Liga doigravanje (M) 2025/26
|
||||
3 klubova (1 matched) Supersport Superliga 2 (M) 2025/26
|
||||
4 klubova (1 matched) Supersport Superliga 2 (Ž) 2025/26
|
||||
0 klubova (0 matched) Superliga
|
||||
47 klubova (3 matched) 1. B liga
|
||||
0 klubova (0 matched) Kup Hrvatske
|
||||
10 klubova (1 matched) Superliga
|
||||
8 klubova (1 matched) Odbojka na pijesku
|
||||
47 klubova (3 matched) 1. B liga
|
||||
19 klubova (2 matched) Mlađe dobne kategorije
|
||||
4 klubova (1 matched) 1. liga
|
||||
8 klubova (1 matched) Odbojka na pijesku
|
||||
19 klubova (2 matched) Mlađe dobne kategorije
|
||||
10 klubova (1 matched) 1. liga
|
||||
47 klubova (3 matched) 1. B liga
|
||||
4 klubova (0 matched) Odbojka na pijesku
|
||||
47 klubova (3 matched) 1. B liga
|
||||
30 klubova (4 matched) 3. liga
|
||||
10 klubova (1 matched) 1. liga
|
||||
4 klubova (0 matched) Odbojka na pijesku
|
||||
3 klubova (1 matched) 1. liga
|
||||
10 klubova (0 matched) Superliga
|
||||
0 klubova (0 matched) Kup Hrvatske
|
||||
52 klubova (4 matched) 2. liga
|
||||
19 klubova (2 matched) 2. liga
|
||||
52 klubova (4 matched) 2. liga
|
||||
52 klubova (4 matched) 2. liga
|
||||
47 klubova (3 matched) 1. B liga
|
||||
0 klubova (0 matched) 3. liga
|
||||
|
||||
=== PGŽ klubovi u HOS ===
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 1. ŽOK DRENOVA 36b -> 4529 'ŽOK Drenova'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. B liga 4. OK GROBNIČAN 28b -> 4528 'MOK Grobničan'
|
||||
1. liga 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
1. liga 1. HAOK RIJEKA 36b -> 2398 'HAOK Rijeka (ranije ŽOK Rijeka)'
|
||||
1. liga 3. ŽOK DRENOVA 2b -> 4529 'ŽOK Drenova'
|
||||
1. liga 4. MOK RIJEKA II 20b -> 4530 'MOK RIJEKA II'
|
||||
2. liga 2. MOK RIJEKA III 12b -> 4532 'MOK RIJEKA III'
|
||||
2. liga 3. MOK GROBNIČAN 8b -> 4528 'MOK Grobničan'
|
||||
2. liga 5. OK KASTAV 1998 6b -> 4531 'OK KASTAV 1998'
|
||||
2. liga 5. OK KASTAV 1998 6b -> 4531 'OK KASTAV 1998'
|
||||
2. liga 5. OK KASTAV 1998 6b -> 4531 'OK KASTAV 1998'
|
||||
3. liga 1. ŽOK DRENOVA 4b -> 4529 'ŽOK Drenova'
|
||||
3. liga 2. OK GROBNIČAN 2b -> 4528 'MOK Grobničan'
|
||||
3. liga 4. HAOK RIJEKA 0b -> 2398 'HAOK Rijeka (ranije ŽOK Rijeka)'
|
||||
Liga doigravanje (M) 2025/26 4. MOK RIJEKA II 20b -> 4530 'MOK RIJEKA II'
|
||||
Mlađe dobne kategorije 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
Mlađe dobne kategorije 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
Mlađe dobne kategorije 4. MOK RIJEKA 4b -> 2467 'MOK Rijeka'
|
||||
Mlađe dobne kategorije 4. MOK RIJEKA 4b -> 2467 'MOK Rijeka'
|
||||
Superliga 8. MOK RIJEKA 12b -> 2467 'MOK Rijeka'
|
||||
Supersport Superliga 2 (M) 202 1. MOK GROBNIČAN 4b -> 4528 'MOK Grobničan'
|
||||
Supersport Superliga 2 (Ž) 202 3. ŽOK DRENOVA 2b -> 4529 'ŽOK Drenova'
|
||||
Supersport Superliga (M) 2025/ 8. MOK RIJEKA 12b -> 4530 'MOK RIJEKA II'
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 8
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 8
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 8
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 8
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 8
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 8
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password='R1net2026!SecureDB#v7')
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 9
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 9
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 9
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
File "/opt/pgz-sport/scrapers/hos_scraper.py", line 9
|
||||
DB = dict(host="10.10.0.2", port=6432, port=5432, dbname='rinet_v3', user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
^^^^^^^^^
|
||||
SyntaxError: keyword argument repeated: port
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('/opt/rinet-gpu/.env.master')
|
||||
# auto-added by patch_scrapers_with_dotenv.sh
|
||||
import os
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: admin_router.py | v1.1.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com>
|
||||
@@ -14,7 +18,7 @@ from datetime import datetime
|
||||
import decimal, uuid
|
||||
|
||||
router = APIRouter(prefix="/admin/api", tags=["admin"])
|
||||
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
|
||||
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
|
||||
|
||||
def db():
|
||||
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
||||
@@ -324,7 +328,7 @@ import psycopg2 as _kpi_pg
|
||||
async def admin_kpi():
|
||||
"""Live KPI metrics — JSON za dashboard."""
|
||||
try:
|
||||
conn = _kpi_pg.connect("host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7", connect_timeout=4)
|
||||
conn = _kpi_pg.connect(f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}", connect_timeout=4)
|
||||
cur = conn.cursor()
|
||||
out = {}
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Fajl: admin_router.py | v1.1.0 | 04.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com>
|
||||
# Lokacija: /opt/pgz-sport/admin_router.py
|
||||
# Svrha: Admin Dashboard ERP+CRM+Tenants — pravo schema
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
"""Admin dashboard backend."""
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from typing import Optional
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from datetime import datetime
|
||||
import decimal, uuid
|
||||
|
||||
router = APIRouter(prefix="/admin/api", tags=["admin"])
|
||||
DSN = f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}"
|
||||
|
||||
def db():
|
||||
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
|
||||
|
||||
def conv(v):
|
||||
if isinstance(v, datetime): return v.isoformat()
|
||||
if isinstance(v, decimal.Decimal): return float(v)
|
||||
if isinstance(v, uuid.UUID): return str(v)
|
||||
return v
|
||||
|
||||
def jsonify(rows):
|
||||
return [{k: conv(v) for k, v in dict(r).items()} for r in rows]
|
||||
|
||||
# ════════ DASHBOARD ════════
|
||||
@router.get("/dashboard")
|
||||
def dashboard(tenant_id: int = Query(1)):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.tenants WHERE id = %s", (tenant_id,))
|
||||
tenant = cur.fetchone()
|
||||
if not tenant: raise HTTPException(404, "Tenant not found")
|
||||
|
||||
cur.execute("SELECT count(*) AS n FROM pgz_sport.klubovi WHERE tenant_id = %s", (tenant_id,))
|
||||
klubovi = cur.fetchone()['n']
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS n FROM pgz_sport.klubovi k WHERE k.tenant_id = %s AND k.last_scraped_at > now() - interval '90 days'
|
||||
""", (tenant_id,))
|
||||
aktivni = cur.fetchone()['n']
|
||||
|
||||
cur.execute("SELECT count(*) AS n FROM pgz_sport.clanovi c JOIN pgz_sport.klubovi k ON k.id=c.klub_id WHERE k.tenant_id = %s", (tenant_id,))
|
||||
osobe = cur.fetchone()['n']
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS n, COALESCE(SUM(amount_gross), 0) AS total_eur
|
||||
FROM pgz_sport.invoices WHERE tenant_id = %s
|
||||
""", (tenant_id,))
|
||||
inv = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS n, COALESCE(SUM(cost_total), 0) AS total_eur
|
||||
FROM pgz_sport.expense_reports WHERE tenant_id = %s
|
||||
""", (tenant_id,))
|
||||
exp = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS n FROM pgz_sport.audit_events
|
||||
WHERE ts > now() - interval '30 days'
|
||||
""")
|
||||
activity = cur.fetchone()['n']
|
||||
|
||||
cur.execute("SELECT id, slug, display_name, type, status FROM pgz_sport.tenants ORDER BY id")
|
||||
tenants = jsonify(cur.fetchall())
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS n FROM pgz_sport.dokumenti
|
||||
WHERE scraped_at > now() - interval '7 days'
|
||||
""")
|
||||
docs_7d = cur.fetchone()['n']
|
||||
|
||||
return {
|
||||
"tenant": jsonify([tenant])[0],
|
||||
"kpi": {
|
||||
"klubovi_total": klubovi,
|
||||
"klubovi_aktivni_90d": aktivni,
|
||||
"osobe": osobe,
|
||||
"invoices": inv['n'],
|
||||
"invoices_total_eur": float(inv['total_eur'] or 0),
|
||||
"expenses": exp['n'],
|
||||
"expenses_total_eur": float(exp['total_eur'] or 0),
|
||||
"activity_30d": activity,
|
||||
"dokumenti_7d": docs_7d
|
||||
},
|
||||
"tenants": tenants
|
||||
}
|
||||
|
||||
# ════════ ERP ════════
|
||||
@router.get("/erp/summary")
|
||||
def erp_summary(tenant_id: int = Query(1)):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT count(*) AS total,
|
||||
count(*) FILTER (WHERE payment_status = 'paid') AS paid,
|
||||
count(*) FILTER (WHERE payment_status = 'pending') AS pending,
|
||||
count(*) FILTER (WHERE payment_status = 'overdue') AS overdue,
|
||||
count(*) FILTER (WHERE payment_status NOT IN ('paid','pending','overdue') OR payment_status IS NULL) AS other,
|
||||
COALESCE(SUM(amount_gross), 0) AS sum_total,
|
||||
COALESCE(SUM(amount_gross) FILTER (WHERE payment_status = 'paid'), 0) AS sum_paid,
|
||||
COALESCE(SUM(amount_gross) FILTER (WHERE payment_status != 'paid' OR payment_status IS NULL), 0) AS sum_unpaid
|
||||
FROM pgz_sport.invoices WHERE tenant_id = %s
|
||||
""", (tenant_id,))
|
||||
inv = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS total, COALESCE(SUM(cost_total), 0) AS sum_total,
|
||||
count(*) FILTER (WHERE status = 'approved') AS approved,
|
||||
count(*) FILTER (WHERE status = 'paid') AS paid_status
|
||||
FROM pgz_sport.expense_reports WHERE tenant_id = %s
|
||||
""", (tenant_id,))
|
||||
exp = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS total, COALESCE(SUM(amount), 0) AS sum_total
|
||||
FROM pgz_sport.payments p
|
||||
JOIN pgz_sport.klubovi k ON k.id = p.klub_id
|
||||
WHERE k.tenant_id = %s AND p.payment_date > now() - interval '90 days'
|
||||
""", (tenant_id,))
|
||||
pay = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT count(*) AS n, COALESCE(SUM(proracun_pgz), 0) AS sum_planirano,
|
||||
COALESCE(SUM(ukupno), 0) AS sum_izvrseno
|
||||
FROM pgz_sport.proracun
|
||||
""")
|
||||
prc = cur.fetchone()
|
||||
|
||||
return {
|
||||
"invoices": jsonify([inv])[0],
|
||||
"expenses": jsonify([exp])[0],
|
||||
"payments_90d": jsonify([pay])[0],
|
||||
"proracun": jsonify([prc])[0]
|
||||
}
|
||||
|
||||
@router.get("/erp/invoices")
|
||||
def erp_invoices(tenant_id: int = Query(1), limit: int = Query(50), status: Optional[str] = None):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
sql = """
|
||||
SELECT i.id, i.invoice_no, i.vendor_name, i.amount_gross, i.currency,
|
||||
i.payment_status, i.invoice_date, i.due_date, i.paid_date,
|
||||
i.klub_id, k.naziv AS klub_naziv
|
||||
FROM pgz_sport.invoices i
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id
|
||||
WHERE i.tenant_id = %s
|
||||
"""
|
||||
params = [tenant_id]
|
||||
if status:
|
||||
sql += " AND i.payment_status = %s"
|
||||
params.append(status)
|
||||
sql += " ORDER BY i.invoice_date DESC NULLS LAST LIMIT %s"
|
||||
params.append(limit)
|
||||
cur.execute(sql, params)
|
||||
rows = jsonify(cur.fetchall())
|
||||
return {"invoices": rows, "count": len(rows)}
|
||||
|
||||
@router.get("/erp/expenses")
|
||||
def erp_expenses(tenant_id: int = Query(1), limit: int = Query(50)):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT e.id, e.klub_id, k.naziv AS klub_naziv, e.report_no,
|
||||
e.destination, e.purpose, e.cost_total, e.dnevnice_amount,
|
||||
e.date_from, e.date_to, e.status, e.created_at
|
||||
FROM pgz_sport.expense_reports e
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = e.klub_id
|
||||
WHERE e.tenant_id = %s
|
||||
ORDER BY e.created_at DESC NULLS LAST LIMIT %s
|
||||
""", (tenant_id, limit))
|
||||
rows = jsonify(cur.fetchall())
|
||||
return {"expenses": rows, "count": len(rows)}
|
||||
|
||||
# ════════ CRM ════════
|
||||
@router.get("/crm/klubovi")
|
||||
def crm_klubovi(tenant_id: int = Query(1), limit: int = Query(50), q: Optional[str] = None):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
sql = """
|
||||
SELECT k.id, k.naziv, k.oib, k.adresa, k.grad, k.email, k.telefon, k.web,
|
||||
k.sport, k.savez_id, k.aktivan,
|
||||
0 AS dokumenti,
|
||||
(SELECT count(*) FROM pgz_sport.invoices i WHERE i.klub_id = k.id) AS invoices_count,
|
||||
(SELECT count(*) FROM pgz_sport.clanovi c WHERE c.klub_id = k.id) AS clanovi,
|
||||
k.last_scraped_at AS last_activity
|
||||
FROM pgz_sport.klubovi k
|
||||
WHERE k.tenant_id = %s
|
||||
"""
|
||||
params = [tenant_id]
|
||||
if q:
|
||||
sql += " AND (k.naziv ILIKE %s OR k.oib LIKE %s OR k.grad ILIKE %s OR k.sport ILIKE %s)"
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
sql += " ORDER BY k.naziv LIMIT %s"
|
||||
params.append(limit)
|
||||
cur.execute(sql, params)
|
||||
rows = jsonify(cur.fetchall())
|
||||
return {"klubovi": rows, "count": len(rows)}
|
||||
|
||||
@router.get("/crm/klub/{klub_id}")
|
||||
def crm_klub_detail(klub_id: int):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.klubovi WHERE id = %s", (klub_id,))
|
||||
klub = cur.fetchone()
|
||||
if not klub: raise HTTPException(404, "Klub not found")
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, title AS naziv, vrsta, sport AS kategorija, scraped_at AS created_at FROM pgz_sport.dokumenti WHERE FALSE LIMIT 20
|
||||
""", (klub_id,))
|
||||
dokumenti = jsonify(cur.fetchall())
|
||||
|
||||
cur.execute("SELECT count(*) AS n FROM pgz_sport.clanovi WHERE klub_id = %s", (klub_id,))
|
||||
clanovi_n = cur.fetchone()['n']
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, invoice_no, vendor_name, amount_gross, payment_status, invoice_date
|
||||
FROM pgz_sport.invoices WHERE klub_id = %s
|
||||
ORDER BY invoice_date DESC LIMIT 10
|
||||
""", (klub_id,))
|
||||
invoices = jsonify(cur.fetchall())
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, report_no, destination, cost_total, status, created_at
|
||||
FROM pgz_sport.expense_reports WHERE klub_id = %s
|
||||
ORDER BY created_at DESC LIMIT 10
|
||||
""", (klub_id,))
|
||||
expenses = jsonify(cur.fetchall())
|
||||
|
||||
return {
|
||||
"klub": jsonify([klub])[0],
|
||||
"dokumenti": dokumenti,
|
||||
"clanovi_count": clanovi_n,
|
||||
"invoices": invoices,
|
||||
"expenses": expenses
|
||||
}
|
||||
|
||||
@router.get("/crm/osobe")
|
||||
def crm_osobe(limit: int = Query(50), q: Optional[str] = None, klub_id: Optional[int] = None):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
sql = """
|
||||
SELECT c.id, c.ime, c.prezime, c.oib, c.email, c.telefon, c.klub_id,
|
||||
k.naziv AS klub_naziv, c.pozicija, c.kategorija, c.aktivan, c.datum_rodenja
|
||||
FROM pgz_sport.clanovi c
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
if q:
|
||||
sql += " AND (c.ime ILIKE %s OR c.prezime ILIKE %s OR c.oib LIKE %s)"
|
||||
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
|
||||
if klub_id:
|
||||
sql += " AND c.klub_id = %s"
|
||||
params.append(klub_id)
|
||||
sql += " ORDER BY c.prezime, c.ime LIMIT %s"
|
||||
params.append(limit)
|
||||
cur.execute(sql, params)
|
||||
rows = jsonify(cur.fetchall())
|
||||
return {"osobe": rows, "count": len(rows)}
|
||||
|
||||
# ════════ TENANTS ════════
|
||||
@router.get("/tenants")
|
||||
def tenants_list():
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.tenants ORDER BY id")
|
||||
rows = jsonify(cur.fetchall())
|
||||
# Add live KPIs
|
||||
for t in rows:
|
||||
cur.execute("SELECT count(*) AS n FROM pgz_sport.klubovi WHERE tenant_id = %s", (t['id'],))
|
||||
t['klubovi_count'] = cur.fetchone()['n']
|
||||
return {"tenants": rows, "count": len(rows)}
|
||||
|
||||
@router.get("/tenants/{tenant_id}")
|
||||
def tenant_detail(tenant_id: int):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM pgz_sport.tenants WHERE id = %s", (tenant_id,))
|
||||
t = cur.fetchone()
|
||||
if not t: raise HTTPException(404, "Not found")
|
||||
return {"tenant": jsonify([t])[0]}
|
||||
|
||||
@router.post("/tenants")
|
||||
def tenants_create(slug: str, display_name: str, oib: Optional[str] = None, type: str = "custom"):
|
||||
with db() as conn:
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO pgz_sport.tenants (slug, display_name, oib, type)
|
||||
VALUES (%s, %s, %s, %s) RETURNING id
|
||||
""", (slug, display_name, oib, type))
|
||||
new_id = cur.fetchone()['id']
|
||||
return {"id": new_id, "slug": slug, "status": "created"}
|
||||
|
||||
# ════════ REPORTS ════════
|
||||
@router.get("/reports/top_klubovi")
|
||||
def reports_top_klubovi(tenant_id: int = Query(1), limit: int = Query(10)):
|
||||
with db() as conn, conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT k.id, k.naziv, k.grad, k.sport,
|
||||
count(DISTINCT d.id) AS dokumenti,
|
||||
count(DISTINCT i.id) AS invoices,
|
||||
count(DISTINCT c.id) AS clanovi
|
||||
FROM pgz_sport.klubovi k
|
||||
LEFT JOIN pgz_sport.dokumenti d ON FALSE
|
||||
LEFT JOIN pgz_sport.invoices i ON i.klub_id = k.id
|
||||
LEFT JOIN pgz_sport.clanovi c ON c.klub_id = k.id
|
||||
WHERE k.tenant_id = %s
|
||||
GROUP BY k.id, k.naziv, k.grad, k.sport
|
||||
ORDER BY (count(DISTINCT d.id) + count(DISTINCT i.id)) DESC
|
||||
LIMIT %s
|
||||
""", (tenant_id, limit))
|
||||
rows = jsonify(cur.fetchall())
|
||||
return {"top_klubovi": rows}
|
||||
|
||||
@router.get("/health")
|
||||
def admin_health():
|
||||
return {"status": "ok", "module": "admin", "version": "1.1.0", "ts": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# KPI Dashboard endpoint (added 04.05.2026 evening sprint)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
import psycopg2 as _kpi_pg
|
||||
|
||||
@router.get("/kpi")
|
||||
async def admin_kpi():
|
||||
"""Live KPI metrics — JSON za dashboard."""
|
||||
try:
|
||||
conn = _kpi_pg.connect(f"host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password={os.environ['DB_PASSWORD']}", connect_timeout=4)
|
||||
cur = conn.cursor()
|
||||
out = {}
|
||||
|
||||
# Capture stats
|
||||
cur.execute("""
|
||||
SELECT
|
||||
count(*) FILTER (WHERE created_at > now() - interval '1 hour') AS h1,
|
||||
count(*) FILTER (WHERE created_at > now() - interval '24 hours') AS h24,
|
||||
count(*) FILTER (WHERE created_at > now() - interval '24 hours' AND is_hallucination) AS halu24,
|
||||
round((avg(processing_time) FILTER (WHERE created_at > now() - interval '24 hours'))::numeric, 1) AS avg_lat,
|
||||
round((avg(confidence) FILTER (WHERE created_at > now() - interval '24 hours'))::numeric, 2) AS avg_conf
|
||||
FROM dabi.input_log
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
out["queries"] = {"h1": r[0], "h24": r[1], "halucinacije_h24": r[2] or 0,
|
||||
"avg_latency_sec": float(r[3]) if r[3] else 0, "avg_confidence": float(r[4]) if r[4] else 0,
|
||||
"halu_pct": round(100*(r[2] or 0)/max(r[1],1), 2)}
|
||||
|
||||
# Knowledge
|
||||
cur.execute("""
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE created_at > now() - interval '1 hour'),
|
||||
count(*) FILTER (WHERE created_at > now() - interval '24 hours'),
|
||||
count(*) FILTER (WHERE embedded_at IS NULL),
|
||||
round(100.0 * count(*) FILTER (WHERE embedded_at IS NOT NULL) / count(*), 2)
|
||||
FROM dabi.knowledge
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
out["knowledge"] = {"total": r[0], "added_h1": r[1], "added_h24": r[2],
|
||||
"embed_pending": r[3], "embed_pct": float(r[4])}
|
||||
|
||||
# Cluster
|
||||
cur.execute("SELECT health_status, count(*) FROM cluster.services GROUP BY health_status")
|
||||
out["cluster"] = {row[0]: row[1] for row in cur.fetchall()}
|
||||
|
||||
cur.execute("SELECT count(*) FROM deploys.incidents WHERE resolved_at IS NULL")
|
||||
out["open_incidents"] = cur.fetchone()[0]
|
||||
|
||||
# Training
|
||||
cur.execute("""
|
||||
SELECT count(*), count(*) FILTER (WHERE source_type='capture_promoted'),
|
||||
count(*) FILTER (WHERE created_at > now() - interval '24 hours')
|
||||
FROM dabi.training_qa
|
||||
""")
|
||||
r = cur.fetchone()
|
||||
out["training"] = {"total": r[0], "from_capture": r[1], "added_h24": r[2]}
|
||||
|
||||
# Top sources last 24h
|
||||
cur.execute("""
|
||||
SELECT source, count(*) FROM dabi.knowledge
|
||||
WHERE created_at > now() - interval '24 hours'
|
||||
GROUP BY source ORDER BY 2 DESC LIMIT 10
|
||||
""")
|
||||
out["top_sources_h24"] = [{"source": s, "count": n} for s, n in cur.fetchall()]
|
||||
|
||||
# Top models last 24h
|
||||
cur.execute("""
|
||||
SELECT model_used, count(*), round(avg(processing_time)::numeric, 1)
|
||||
FROM dabi.input_log WHERE created_at > now() - interval '24 hours'
|
||||
GROUP BY model_used ORDER BY 2 DESC LIMIT 5
|
||||
""")
|
||||
out["top_models_h24"] = [{"model": m, "count": n, "avg_latency": float(l) if l else 0} for m, n, l in cur.fetchall()]
|
||||
|
||||
cur.close(); conn.close()
|
||||
return out
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@router.get("/kpi-page", include_in_schema=False)
|
||||
async def admin_kpi_html():
|
||||
"""HTML KPI dashboard page."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("/opt/pgz-sport/static/kpi.html")
|
||||
@@ -370,6 +370,34 @@ def admin_reset_password(uid: int, request: Request, actor = Depends(require_use
|
||||
{"email": target["email"]}, ip, ua)
|
||||
return {"status": "ok", "temporary_password": new_temp}
|
||||
|
||||
# ─────────────────────────── 2FA admin (status / force disable) ───────────────────────────
|
||||
@router.get("/users/{uid}/2fa-status")
|
||||
def admin_2fa_status(uid: int, actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s", (uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
row = db_one("""SELECT enabled, verified_at, created_at, updated_at
|
||||
FROM pgz_sport.user_2fa WHERE user_id=%s""", (uid,))
|
||||
return {"enabled": bool(row and row.get("enabled")),
|
||||
"verified_at": row and row.get("verified_at"),
|
||||
"created_at": row and row.get("created_at"),
|
||||
"updated_at": row and row.get("updated_at")}
|
||||
|
||||
@router.post("/users/{uid}/2fa-disable")
|
||||
def admin_2fa_disable(uid: int, request: Request, actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (uid,))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.2fa.admin_disable", "user", uid,
|
||||
{"email": target["email"]}, ip, ua)
|
||||
return {"status": "ok", "id": uid, "two_factor_enabled": False}
|
||||
|
||||
# ─────────────────────────── Audit log ───────────────────────────
|
||||
@router.get("/audit")
|
||||
def audit_log(user_id: Optional[int] = None,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('/opt/rinet-gpu/.env.master')
|
||||
# auto-added by patch_scrapers_with_dotenv.sh
|
||||
# auth_v2.py — JWT auth backend with tenant_id, role, tier claims
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout,
|
||||
@@ -33,7 +36,7 @@ except Exception:
|
||||
HAS_BCRYPT = False
|
||||
|
||||
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
|
||||
user='rinet', password='R1net2026!SecureDB#v7')
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
|
||||
# Persistent JWT secret — read from env, else stable file, else generated.
|
||||
def _load_secret() -> str:
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
#!/usr/bin/env python3
|
||||
# auth_v2.py — JWT auth backend with tenant_id, role, tier claims
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout,
|
||||
# /api/auth/me, /api/auth/password/change, /api/auth/password/reset
|
||||
"""
|
||||
JWT claims:
|
||||
sub int user id
|
||||
email str
|
||||
name str
|
||||
tenant_id int|null pgz_sport.tenants.id (or null for super_admin)
|
||||
tenant_type str pgz | savez | klub | global
|
||||
tenant_scope dict {"klub_id": ..., "savez_id": ...}
|
||||
role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...)
|
||||
tier int 0 = PGŽ, 1 = savez, 2 = klub
|
||||
jti str token id (revocable via user_sessions)
|
||||
iat / exp / nbf
|
||||
"""
|
||||
|
||||
import os, hashlib, secrets, json, time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
import jwt as _jwt
|
||||
import psycopg2, psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
try:
|
||||
from passlib.hash import bcrypt as _bcrypt
|
||||
HAS_BCRYPT = True
|
||||
except Exception:
|
||||
HAS_BCRYPT = False
|
||||
|
||||
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
|
||||
user='rinet', password=os.environ["DB_PASSWORD"])
|
||||
|
||||
# Persistent JWT secret — read from env, else stable file, else generated.
|
||||
def _load_secret() -> str:
|
||||
env_secret = os.environ.get("PGZ_JWT_SECRET")
|
||||
if env_secret and len(env_secret) >= 32:
|
||||
return env_secret
|
||||
secret_file = "/opt/pgz-sport/auth/.jwt_secret"
|
||||
try:
|
||||
if os.path.exists(secret_file):
|
||||
with open(secret_file) as f:
|
||||
s = f.read().strip()
|
||||
if len(s) >= 32:
|
||||
return s
|
||||
s = "rinet-pgz-" + secrets.token_urlsafe(48)
|
||||
with open(secret_file, "w") as f:
|
||||
f.write(s)
|
||||
os.chmod(secret_file, 0o600)
|
||||
return s
|
||||
except Exception:
|
||||
return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest()
|
||||
|
||||
JWT_SECRET = _load_secret()
|
||||
JWT_ALG = "HS256"
|
||||
ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30")))
|
||||
REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7")))
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth_v2"])
|
||||
|
||||
# ─────────────────────────── DB helpers ───────────────────────────
|
||||
def _conn():
|
||||
return psycopg2.connect(**DB)
|
||||
|
||||
def db_query(sql: str, params=()):
|
||||
with _conn() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql, params)
|
||||
if cur.description: return cur.fetchall()
|
||||
return []
|
||||
|
||||
def db_one(sql: str, params=()):
|
||||
rows = db_query(sql, params)
|
||||
return rows[0] if rows else None
|
||||
|
||||
def db_exec(sql: str, params=()):
|
||||
with _conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(sql, params)
|
||||
if cur.description:
|
||||
r = cur.fetchone()
|
||||
return r[0] if r else None
|
||||
c.commit()
|
||||
|
||||
# ─────────────────────────── Password helpers ───────────────────────────
|
||||
def _sha256(pw: str) -> str:
|
||||
return hashlib.sha256(pw.encode()).hexdigest()
|
||||
|
||||
def hash_password(pw: str) -> str:
|
||||
if HAS_BCRYPT:
|
||||
return _bcrypt.using(rounds=12).hash(pw)
|
||||
return _sha256(pw)
|
||||
|
||||
def verify_password(pw: str, hashed: Optional[str]) -> bool:
|
||||
if not hashed: return False
|
||||
h = hashed.strip()
|
||||
if h.startswith("$2") and HAS_BCRYPT:
|
||||
try:
|
||||
return _bcrypt.verify(pw, h)
|
||||
except Exception:
|
||||
return False
|
||||
return h == _sha256(pw)
|
||||
|
||||
def needs_rehash(hashed: Optional[str]) -> bool:
|
||||
if not hashed: return True
|
||||
return HAS_BCRYPT and not hashed.startswith("$2")
|
||||
|
||||
# ─────────────────────────── Tenant resolution ───────────────────────────
|
||||
PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"}
|
||||
SAVEZ_USER_TYPES = {"savez_admin", "savez_user"}
|
||||
KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"}
|
||||
|
||||
def _tier_for(user_type: str) -> int:
|
||||
ut = (user_type or "").lower()
|
||||
if ut in PGZ_USER_TYPES: return 0
|
||||
if ut in SAVEZ_USER_TYPES: return 1
|
||||
if ut in KLUB_USER_TYPES: return 2
|
||||
return 9 # unknown / viewer / guest
|
||||
|
||||
def _resolve_tenant(u: Dict) -> Dict:
|
||||
"""Resolve tenant_id + tenant_type from a user row."""
|
||||
ut = (u.get("user_type") or "").lower()
|
||||
klub_id = u.get("klub_id")
|
||||
savez_id = u.get("savez_id")
|
||||
if ut in PGZ_USER_TYPES:
|
||||
row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1")
|
||||
return {
|
||||
"tenant_id": row["id"] if row else None,
|
||||
"tenant_type": "pgz",
|
||||
"tenant_name": row["display_name"] if row else "PGŽ",
|
||||
"tenant_scope": {"klub_id": None, "savez_id": None},
|
||||
}
|
||||
if ut in SAVEZ_USER_TYPES and savez_id:
|
||||
return {
|
||||
"tenant_id": savez_id,
|
||||
"tenant_type": "savez",
|
||||
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"),
|
||||
"tenant_scope": {"klub_id": None, "savez_id": savez_id},
|
||||
}
|
||||
if ut in KLUB_USER_TYPES and klub_id:
|
||||
return {
|
||||
"tenant_id": klub_id,
|
||||
"tenant_type": "klub",
|
||||
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"),
|
||||
"tenant_scope": {"klub_id": klub_id, "savez_id": savez_id},
|
||||
}
|
||||
# super_admin without context
|
||||
if ut == "super_admin":
|
||||
return {"tenant_id": None, "tenant_type": "global",
|
||||
"tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}}
|
||||
return {"tenant_id": None, "tenant_type": "viewer",
|
||||
"tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}}
|
||||
|
||||
# ─────────────────────────── JWT issue / verify ───────────────────────────
|
||||
def _now() -> datetime: return datetime.now(timezone.utc)
|
||||
|
||||
def _new_jti() -> str: return secrets.token_urlsafe(16)
|
||||
|
||||
def make_access_token(u: Dict, jti: str) -> str:
|
||||
tenant = _resolve_tenant(u)
|
||||
tier = _tier_for(u.get("user_type") or "")
|
||||
now = _now()
|
||||
payload = {
|
||||
"sub": str(u["id"]),
|
||||
"uid": u["id"],
|
||||
"email": u["email"],
|
||||
"name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"],
|
||||
"tenant_id": tenant["tenant_id"],
|
||||
"tenant_type": tenant["tenant_type"],
|
||||
"tenant_name": tenant["tenant_name"],
|
||||
"tenant_scope": tenant["tenant_scope"],
|
||||
"role": u.get("user_type") or "viewer",
|
||||
"tier": tier,
|
||||
"jti": jti,
|
||||
"typ": "access",
|
||||
"iat": int(now.timestamp()),
|
||||
"nbf": int(now.timestamp()),
|
||||
"exp": int((now + ACCESS_TTL).timestamp()),
|
||||
}
|
||||
return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
|
||||
|
||||
def make_refresh_token(uid: int, jti: str) -> str:
|
||||
now = _now()
|
||||
return _jwt.encode({
|
||||
"sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + REFRESH_TTL).timestamp()),
|
||||
}, JWT_SECRET, algorithm=JWT_ALG)
|
||||
|
||||
def decode_token(token: str) -> Dict:
|
||||
try:
|
||||
return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
|
||||
except _jwt.ExpiredSignatureError:
|
||||
raise HTTPException(401, "Token expired")
|
||||
except Exception as e:
|
||||
raise HTTPException(401, f"Invalid token: {e}")
|
||||
|
||||
def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None):
|
||||
th = hashlib.sha256(jti.encode()).hexdigest()
|
||||
db_exec("""INSERT INTO pgz_sport.user_sessions
|
||||
(user_id, token_hash, device_info, ip_address, expires_at, revoked)
|
||||
VALUES (%s,%s,%s,%s::inet,%s,false)
|
||||
ON CONFLICT (token_hash) DO NOTHING""",
|
||||
(uid, th, ua, ip, expires))
|
||||
|
||||
def _is_revoked(jti: str) -> bool:
|
||||
th = hashlib.sha256(jti.encode()).hexdigest()
|
||||
r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,))
|
||||
if not r: return False
|
||||
return bool(r.get("revoked"))
|
||||
|
||||
def _revoke_jti(jti: str):
|
||||
th = hashlib.sha256(jti.encode()).hexdigest()
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,))
|
||||
|
||||
# ─────────────────────────── current_user dep ───────────────────────────
|
||||
def _extract_token(authorization: Optional[str]) -> Optional[str]:
|
||||
if not authorization: return None
|
||||
return authorization.replace("Bearer ", "").strip() or None
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]:
|
||||
token = _extract_token(authorization)
|
||||
if not token: return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except HTTPException:
|
||||
return None
|
||||
if payload.get("typ") not in (None, "access"):
|
||||
return None
|
||||
if _is_revoked(payload.get("jti","")):
|
||||
return None
|
||||
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
|
||||
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, status, aktivan, must_change_pwd
|
||||
FROM pgz_sport.users WHERE id=%s""", (uid,))
|
||||
if not u or u.get("status") != "active" or not u.get("aktivan", True):
|
||||
return None
|
||||
u["_jwt"] = payload
|
||||
u["_token"] = token
|
||||
return u
|
||||
|
||||
def require_user(user = Depends(get_current_user)) -> Dict:
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
def require_role(roles: List[str]):
|
||||
def dep(user = Depends(require_user)):
|
||||
if user.get("user_type") not in roles:
|
||||
raise HTTPException(403, f"Forbidden — required: {','.join(roles)}")
|
||||
return user
|
||||
return dep
|
||||
|
||||
# ─────────────────────────── Audit ───────────────────────────
|
||||
def audit(user_id: Optional[int], action: str, resource_type: str = None,
|
||||
resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None):
|
||||
try:
|
||||
db_exec("""INSERT INTO pgz_sport.audit_events
|
||||
(user_id, action, resource_type, resource_id, meta, ip_address, user_agent)
|
||||
VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""",
|
||||
(user_id, action, resource_type, resource_id,
|
||||
json.dumps(meta or {}), ip, ua))
|
||||
except Exception as e:
|
||||
print(f"[AUDIT WARN] {e}")
|
||||
|
||||
def _client(req: Request):
|
||||
ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None
|
||||
ua = req.headers.get("user-agent")
|
||||
return ip, ua
|
||||
|
||||
# ─────────────────────────── Schemas ───────────────────────────
|
||||
class LoginReq(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
totp: Optional[str] = None # 6-digit TOTP if 2FA enabled (or recovery code)
|
||||
|
||||
class RefreshReq(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class ChangePwdReq(BaseModel):
|
||||
old_password: Optional[str] = None
|
||||
new_password: str
|
||||
|
||||
class ResetPwdReq(BaseModel):
|
||||
email: str
|
||||
|
||||
# ─────────────────────────── Rate limiting (R6 #5) ───────────────────────────
|
||||
LOCK_THRESHOLD = int(os.environ.get("PGZ_LOGIN_LOCK_THRESHOLD", "5"))
|
||||
LOCK_MINUTES = int(os.environ.get("PGZ_LOGIN_LOCK_MINUTES", "5"))
|
||||
IP_THRESHOLD = int(os.environ.get("PGZ_LOGIN_IP_THRESHOLD", "10"))
|
||||
IP_WINDOW_SEC = int(os.environ.get("PGZ_LOGIN_IP_WINDOW_SEC", "300")) # 5 min
|
||||
|
||||
# In-memory IP throttle: ip → list[float fail timestamps within window]
|
||||
_ip_fail_log: Dict[str, List[float]] = {}
|
||||
|
||||
def _ip_record_fail(ip: Optional[str]):
|
||||
if not ip: return
|
||||
now = time.time()
|
||||
arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC]
|
||||
arr.append(now)
|
||||
_ip_fail_log[ip] = arr
|
||||
|
||||
def _ip_blocked(ip: Optional[str]) -> Optional[int]:
|
||||
"""Return seconds-until-unblock, or None if not blocked."""
|
||||
if not ip: return None
|
||||
now = time.time()
|
||||
arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC]
|
||||
_ip_fail_log[ip] = arr
|
||||
if len(arr) < IP_THRESHOLD: return None
|
||||
oldest = min(arr)
|
||||
return max(1, int(IP_WINDOW_SEC - (now - oldest)))
|
||||
|
||||
def _ip_clear(ip: Optional[str]):
|
||||
if ip and ip in _ip_fail_log:
|
||||
_ip_fail_log.pop(ip, None)
|
||||
|
||||
# ─────────────────────────── Endpoints ───────────────────────────
|
||||
@router.post("/login")
|
||||
def login(req: LoginReq, request: Request):
|
||||
ip, ua = _client(request)
|
||||
email = (req.email or "").lower().strip()
|
||||
if not email or not req.password:
|
||||
raise HTTPException(400, "Email i lozinka obavezni")
|
||||
|
||||
# R6 #5: per-IP throttle (stops brute-force across many emails)
|
||||
blocked_for = _ip_blocked(ip)
|
||||
if blocked_for:
|
||||
audit(None, "login.ratelimit.ip",
|
||||
meta={"email": email, "ip": ip, "block_seconds": blocked_for},
|
||||
ip=ip, ua=ua)
|
||||
raise HTTPException(429, f"Previše pokušaja s ove IP adrese — pokušajte za {blocked_for}s")
|
||||
|
||||
u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status,
|
||||
user_type, klub_id, savez_id, aktivan, must_change_pwd,
|
||||
failed_login_count, locked_until
|
||||
FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,))
|
||||
if not u:
|
||||
_ip_record_fail(ip)
|
||||
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
|
||||
raise HTTPException(401, "Neispravni podaci")
|
||||
if u.get("locked_until"):
|
||||
lu = u["locked_until"]
|
||||
if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc)
|
||||
if lu > _now():
|
||||
audit(u["id"], "login.locked", ip=ip, ua=ua)
|
||||
raise HTTPException(423, "Račun privremeno zaključan")
|
||||
if u.get("status") != "active" or not u.get("aktivan", True):
|
||||
audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua)
|
||||
raise HTTPException(403, "Račun nije aktivan")
|
||||
if not verify_password(req.password, u.get("password_hash")):
|
||||
# R6 #5: 5 fails → 5-minute lockout
|
||||
new_fails = (u.get("failed_login_count") or 0) + 1
|
||||
will_lock = new_fails >= LOCK_THRESHOLD
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET failed_login_count = %s,
|
||||
locked_until = CASE WHEN %s
|
||||
THEN now() + (interval '1 minute' * %s)
|
||||
ELSE locked_until END
|
||||
WHERE id=%s""",
|
||||
(new_fails, will_lock, LOCK_MINUTES, u["id"]))
|
||||
_ip_record_fail(ip)
|
||||
audit(u["id"], "login.fail",
|
||||
meta={"reason":"bad_password", "fails": new_fails,
|
||||
"locked": bool(will_lock),
|
||||
"lock_minutes": LOCK_MINUTES if will_lock else 0},
|
||||
ip=ip, ua=ua)
|
||||
raise HTTPException(401,
|
||||
f"Neispravni podaci ({new_fails}/{LOCK_THRESHOLD})" +
|
||||
(f" — račun je zaključan na {LOCK_MINUTES} minuta" if will_lock else ""))
|
||||
|
||||
# opportunistic rehash to bcrypt
|
||||
if needs_rehash(u.get("password_hash")):
|
||||
try:
|
||||
db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s",
|
||||
(hash_password(req.password), u["id"]))
|
||||
except Exception: pass
|
||||
|
||||
# 2FA gate — if user has enabled 2FA, demand a valid TOTP / recovery code
|
||||
twofa_row = None
|
||||
try:
|
||||
twofa_row = db_one("SELECT secret, enabled, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||
(u["id"],))
|
||||
except Exception: pass
|
||||
if twofa_row and twofa_row.get("enabled"):
|
||||
code = (req.totp or "").strip().replace(" ", "")
|
||||
if not code:
|
||||
audit(u["id"], "login.2fa_required", ip=ip, ua=ua)
|
||||
raise HTTPException(401, "2FA_REQUIRED")
|
||||
ok = False
|
||||
if code.isdigit() and len(code) in (6, 8) and HAS_PYOTP:
|
||||
ok = _pyotp.TOTP(twofa_row["secret"]).verify(code, valid_window=1)
|
||||
if not ok and twofa_row.get("recovery_codes"):
|
||||
up = code.upper()
|
||||
if up in (twofa_row["recovery_codes"] or []):
|
||||
ok = True
|
||||
# consume the recovery code so it can't be reused
|
||||
remaining = [c for c in twofa_row["recovery_codes"] if c != up]
|
||||
db_exec("UPDATE pgz_sport.user_2fa SET recovery_codes=%s, updated_at=now() WHERE user_id=%s",
|
||||
(remaining, u["id"]))
|
||||
if not ok:
|
||||
audit(u["id"], "login.2fa_fail", ip=ip, ua=ua)
|
||||
raise HTTPException(401, "Neispravan 2FA kod")
|
||||
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET failed_login_count=0, locked_until=NULL, last_login=now()
|
||||
WHERE id=%s""", (u["id"],))
|
||||
_ip_clear(ip) # successful login clears IP throttle
|
||||
|
||||
jti = _new_jti()
|
||||
rjti = _new_jti()
|
||||
access = make_access_token(u, jti)
|
||||
refresh = make_refresh_token(u["id"], rjti)
|
||||
_record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
|
||||
_record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]")
|
||||
audit(u["id"], "login.ok", ip=ip, ua=ua)
|
||||
|
||||
tenant = _resolve_tenant(u)
|
||||
return {
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(ACCESS_TTL.total_seconds()),
|
||||
"user": {
|
||||
"id": u["id"], "email": u["email"],
|
||||
"full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(),
|
||||
"role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""),
|
||||
"must_change_pwd": bool(u.get("must_change_pwd")),
|
||||
**tenant,
|
||||
},
|
||||
}
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh(req: RefreshReq, request: Request):
|
||||
payload = decode_token(req.refresh_token)
|
||||
if payload.get("typ") != "refresh":
|
||||
raise HTTPException(401, "Invalid refresh token")
|
||||
if _is_revoked(payload.get("jti","")):
|
||||
raise HTTPException(401, "Refresh token revoked")
|
||||
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
|
||||
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, status, aktivan, must_change_pwd
|
||||
FROM pgz_sport.users WHERE id=%s""", (uid,))
|
||||
if not u or u.get("status") != "active" or not u.get("aktivan", True):
|
||||
raise HTTPException(401, "User inactive")
|
||||
ip, ua = _client(request)
|
||||
new_jti = _new_jti()
|
||||
access = make_access_token(u, new_jti)
|
||||
_record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
|
||||
audit(u["id"], "auth.refresh", ip=ip, ua=ua)
|
||||
return {"access_token": access, "token_type": "Bearer",
|
||||
"expires_in": int(ACCESS_TTL.total_seconds())}
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, user = Depends(require_user)):
|
||||
jti = (user.get("_jwt") or {}).get("jti")
|
||||
if jti: _revoke_jti(jti)
|
||||
# Also revoke refresh tokens for this user (best-effort)
|
||||
db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true
|
||||
WHERE user_id=%s AND device_info LIKE %s""",
|
||||
(user["id"], "%[refresh]%"))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "logout", ip=ip, ua=ua)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/me")
|
||||
def me(user = Depends(require_user)):
|
||||
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, must_change_pwd, aktivan, status,
|
||||
last_login, oib, telefon, phone, preferred_language, created_at,
|
||||
avatar_url, gdpr_consent_at, google_picture
|
||||
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
|
||||
if not enriched:
|
||||
raise HTTPException(404, "User not found")
|
||||
tenant = _resolve_tenant(enriched)
|
||||
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
|
||||
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
||||
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
|
||||
try:
|
||||
twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||
(user["id"],)) or {"enabled": False}
|
||||
except Exception:
|
||||
twofa = {"enabled": False}
|
||||
return {**enriched,
|
||||
"tier": _tier_for(enriched.get("user_type") or ""),
|
||||
"must_change_pwd": bool(enriched.get("must_change_pwd")),
|
||||
"two_factor_enabled": bool(twofa.get("enabled")),
|
||||
**tenant, "roles": roles}
|
||||
|
||||
class UpdateMeReq(BaseModel):
|
||||
ime: Optional[str] = None
|
||||
prezime: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
telefon: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
preferred_language: Optional[str] = None
|
||||
oib: Optional[str] = None
|
||||
|
||||
@router.put("/me")
|
||||
def update_me(req: UpdateMeReq, request: Request, user = Depends(require_user)):
|
||||
fields = []
|
||||
vals: List[Any] = []
|
||||
for k in ("ime","prezime","full_name","telefon","phone","preferred_language","oib"):
|
||||
v = getattr(req, k)
|
||||
if v is not None:
|
||||
fields.append(f"{k}=%s")
|
||||
vals.append(v.strip() if isinstance(v, str) else v)
|
||||
if not fields:
|
||||
raise HTTPException(400, "Nema polja za ažuriranje")
|
||||
vals.append(user["id"])
|
||||
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)}, updated_at=now() WHERE id=%s", tuple(vals))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "profile.update", meta={"fields": [f.split("=")[0] for f in fields]}, ip=ip, ua=ua)
|
||||
# Re-fetch fresh user data and return same shape as GET /me
|
||||
fresh = db_one("SELECT * FROM pgz_sport.users WHERE id=%s", (user["id"],))
|
||||
if not fresh:
|
||||
raise HTTPException(404, "User not found after update")
|
||||
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, must_change_pwd, aktivan, status,
|
||||
last_login, oib, telefon, phone, preferred_language, created_at,
|
||||
avatar_url, gdpr_consent_at, google_picture
|
||||
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
|
||||
tenant = _resolve_tenant(enriched)
|
||||
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
|
||||
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
||||
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
|
||||
try:
|
||||
twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||
(user["id"],)) or {"enabled": False}
|
||||
except Exception:
|
||||
twofa = {"enabled": False}
|
||||
return {**enriched,
|
||||
"tier": _tier_for(enriched.get("user_type") or ""),
|
||||
"must_change_pwd": bool(enriched.get("must_change_pwd")),
|
||||
"two_factor_enabled": bool(twofa.get("enabled")),
|
||||
**tenant, "roles": roles}
|
||||
|
||||
# ─────────────────────────── AVATAR UPLOAD ───────────────────────────
|
||||
import shutil, pathlib
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
UPLOAD_ROOT = pathlib.Path("/opt/pgz-sport/uploads")
|
||||
AVATAR_DIR = UPLOAD_ROOT / "avatars"
|
||||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ALLOWED_AVATAR_MIME = {"image/jpeg","image/jpg","image/png","image/webp"}
|
||||
ALLOWED_AVATAR_EXT = {".jpg",".jpeg",".png",".webp"}
|
||||
MAX_AVATAR_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
@router.post("/me/avatar")
|
||||
async def upload_my_avatar(request: Request, file: UploadFile = File(...), user = Depends(require_user)):
|
||||
ct = (file.content_type or "").lower()
|
||||
if ct not in ALLOWED_AVATAR_MIME:
|
||||
raise HTTPException(400, f"Nedozvoljen tip slike: {ct} — jpeg/png/webp")
|
||||
ext = pathlib.Path(file.filename or "").suffix.lower()
|
||||
if ext not in ALLOWED_AVATAR_EXT:
|
||||
ext = {"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp"}.get(ct, ".jpg")
|
||||
data = await file.read()
|
||||
if len(data) > MAX_AVATAR_BYTES:
|
||||
raise HTTPException(413, f"Slika prevelika ({len(data)} B > {MAX_AVATAR_BYTES})")
|
||||
if len(data) < 32:
|
||||
raise HTTPException(400, "Slika prazna ili neispravna")
|
||||
safe_name = f"{int(user['id'])}_{int(time.time())}{ext}"
|
||||
target = AVATAR_DIR / safe_name
|
||||
with open(target, "wb") as f:
|
||||
f.write(data)
|
||||
try: os.chmod(target, 0o644)
|
||||
except Exception: pass
|
||||
avatar_url = f"/uploads/avatars/{safe_name}"
|
||||
db_exec("UPDATE pgz_sport.users SET avatar_url=%s, updated_at=now() WHERE id=%s",
|
||||
(avatar_url, user["id"]))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "profile.avatar_upload",
|
||||
meta={"file": safe_name, "size": len(data), "mime": ct}, ip=ip, ua=ua)
|
||||
return {"status":"ok", "avatar_url": avatar_url, "size": len(data), "mime": ct}
|
||||
|
||||
@router.delete("/me/avatar")
|
||||
def delete_my_avatar(request: Request, user = Depends(require_user)):
|
||||
cur = db_one("SELECT avatar_url FROM pgz_sport.users WHERE id=%s", (user["id"],))
|
||||
if cur and cur.get("avatar_url"):
|
||||
p = AVATAR_DIR / pathlib.Path(cur["avatar_url"]).name
|
||||
try:
|
||||
if p.exists() and p.is_relative_to(AVATAR_DIR): p.unlink()
|
||||
except Exception: pass
|
||||
db_exec("UPDATE pgz_sport.users SET avatar_url=NULL, updated_at=now() WHERE id=%s", (user["id"],))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "profile.avatar_delete", ip=ip, ua=ua)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post("/password/change")
|
||||
def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)):
|
||||
if len(req.new_password) < 8:
|
||||
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
|
||||
cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s",
|
||||
(user["id"],))
|
||||
if not cur: raise HTTPException(404, "User not found")
|
||||
if not cur.get("must_change_pwd"):
|
||||
if not req.old_password:
|
||||
raise HTTPException(400, "old_password obavezan")
|
||||
if not verify_password(req.old_password, cur.get("password_hash")):
|
||||
raise HTTPException(401, "Stara lozinka netočna")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=false, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(req.new_password), user["id"]))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "password.change", ip=ip, ua=ua)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post("/password/reset")
|
||||
def password_reset(req: ResetPwdReq, request: Request):
|
||||
"""Issue a temporary password (admin-equivalent self-reset; logged)."""
|
||||
email = (req.email or "").lower().strip()
|
||||
u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||
(email,))
|
||||
ip, ua = _client(request)
|
||||
audit(u["id"] if u else None, "password.reset.request",
|
||||
meta={"email": email, "found": bool(u)}, ip=ip, ua=ua)
|
||||
# Generic response — do not leak which emails exist
|
||||
return {"status": "ok",
|
||||
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
|
||||
|
||||
# ─────────────────────────── R5 #2+#3: invite & reset tokens ───────────────────────────
|
||||
def _ensure_token_table():
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_action_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, -- 'invite' | 'reset'
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_by INTEGER REFERENCES pgz_sport.users(id),
|
||||
ip TEXT,
|
||||
meta JSONB
|
||||
)""")
|
||||
db_exec("""CREATE INDEX IF NOT EXISTS idx_action_tokens_user
|
||||
ON pgz_sport.user_action_tokens (user_id, kind, used_at)""")
|
||||
_ensure_token_table()
|
||||
|
||||
INVITE_TTL = timedelta(days=int(os.environ.get("PGZ_INVITE_TTL_DAYS", "7")))
|
||||
RESET_TTL = timedelta(hours=int(os.environ.get("PGZ_RESET_TTL_HOURS", "2")))
|
||||
|
||||
def _make_action_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def _hash_action_token(t: str) -> str:
|
||||
return hashlib.sha256(t.encode()).hexdigest()
|
||||
|
||||
def issue_action_token(user_id: int, kind: str, ttl: timedelta,
|
||||
created_by: Optional[int] = None,
|
||||
ip: Optional[str] = None,
|
||||
meta: Optional[Dict] = None) -> str:
|
||||
"""Create a one-time URL-safe token; only its sha256 is persisted."""
|
||||
if kind not in ("invite", "reset"):
|
||||
raise ValueError("kind must be invite|reset")
|
||||
# Invalidate any prior unused tokens of same kind for this user
|
||||
db_exec("""UPDATE pgz_sport.user_action_tokens SET used_at=now()
|
||||
WHERE user_id=%s AND kind=%s AND used_at IS NULL""",
|
||||
(user_id, kind))
|
||||
raw = _make_action_token()
|
||||
th = _hash_action_token(raw)
|
||||
db_exec("""INSERT INTO pgz_sport.user_action_tokens
|
||||
(token_hash, user_id, kind, expires_at, created_by, ip, meta)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""",
|
||||
(th, user_id, kind, _now() + ttl, created_by, ip, json.dumps(meta or {})))
|
||||
return raw
|
||||
|
||||
def consume_action_token(raw: str, kind: str) -> Optional[Dict]:
|
||||
"""Validate (kind/expiry/unused) and atomically mark used_at. Returns row dict if OK."""
|
||||
th = _hash_action_token(raw)
|
||||
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, t.kind, t.meta,
|
||||
u.email, u.aktivan, u.status
|
||||
FROM pgz_sport.user_action_tokens t
|
||||
JOIN pgz_sport.users u ON u.id = t.user_id
|
||||
WHERE t.token_hash=%s AND t.kind=%s""", (th, kind))
|
||||
if not row: return None
|
||||
if row["used_at"] is not None: return None
|
||||
exp = row["expires_at"]
|
||||
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= _now(): return None
|
||||
db_exec("UPDATE pgz_sport.user_action_tokens SET used_at=now() WHERE token_hash=%s", (th,))
|
||||
return row
|
||||
|
||||
def _build_link(path: str, token: str) -> str:
|
||||
base = os.environ.get("PGZ_PUBLIC_BASE", "https://api.rinet.one/sport")
|
||||
sep = '&' if '?' in path else '?'
|
||||
return f"{base}{path}{sep}token={token}"
|
||||
|
||||
# ─────────────────────────── /auth/forgot-password ───────────────────────────
|
||||
class ForgotPwdReq(BaseModel):
|
||||
email: str
|
||||
|
||||
@router.post("/forgot-password")
|
||||
def forgot_password(req: ForgotPwdReq, request: Request):
|
||||
"""Always returns a generic message — never leaks which emails exist.
|
||||
Issues a reset token only if the user exists and is active, then
|
||||
sends a (mock) e-mail with the reset link."""
|
||||
email = (req.email or "").lower().strip()
|
||||
ip, ua = _client(request)
|
||||
u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||
(email,))
|
||||
token = None
|
||||
mail_result = None
|
||||
if u and u.get("aktivan") and u.get("status") == "active":
|
||||
token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip,
|
||||
meta={"email": email})
|
||||
reset_link = _build_link("/static/login.html?reset=1", token)
|
||||
try:
|
||||
from .mailer import send_password_reset
|
||||
mail_result = send_password_reset(email, reset_link,
|
||||
int(RESET_TTL.total_seconds()))
|
||||
except Exception as e:
|
||||
print(f"[forgot_password mail WARN] {e}")
|
||||
audit(u["id"], "password.forgot.issue",
|
||||
meta={"email": email,
|
||||
"ttl_hours": RESET_TTL.total_seconds()/3600,
|
||||
"mail_sent": bool(mail_result and mail_result.get("sent")),
|
||||
"mail_mock": bool(mail_result and mail_result.get("mock"))},
|
||||
ip=ip, ua=ua)
|
||||
else:
|
||||
audit(u["id"] if u else None, "password.forgot.miss",
|
||||
meta={"email": email}, ip=ip, ua=ua)
|
||||
# Generic response — do not leak account existence
|
||||
resp = {"status": "ok",
|
||||
"message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."}
|
||||
# Reveal link only on localhost or with explicit env flag (debugging).
|
||||
# Real users get it via e-mail.
|
||||
if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or
|
||||
(request.client.host in ("127.0.0.1", "::1"))):
|
||||
resp["reset_link"] = _build_link("/static/login.html?reset=1", token)
|
||||
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
|
||||
resp["mail_mock"] = bool(mail_result and mail_result.get("mock"))
|
||||
return resp
|
||||
|
||||
class ResetTokenReq(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@router.post("/reset-password")
|
||||
def reset_password_with_token(req: ResetTokenReq, request: Request):
|
||||
"""Consume a reset token and set a new password."""
|
||||
if len(req.new_password or "") < 8:
|
||||
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
|
||||
row = consume_action_token(req.token, "reset")
|
||||
ip, ua = _client(request)
|
||||
if not row:
|
||||
audit(None, "password.reset.fail",
|
||||
meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua)
|
||||
raise HTTPException(400, "Token je nevažeći ili istekao")
|
||||
if not row.get("aktivan") or row.get("status") != "active":
|
||||
audit(row["user_id"], "password.reset.fail",
|
||||
meta={"reason": "user_inactive"}, ip=ip, ua=ua)
|
||||
raise HTTPException(403, "Račun nije aktivan")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=false,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(req.new_password), row["user_id"]))
|
||||
# Revoke all active sessions for safety
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
|
||||
(row["user_id"],))
|
||||
audit(row["user_id"], "password.reset.ok", ip=ip, ua=ua)
|
||||
return {"status": "ok", "email": row["email"]}
|
||||
|
||||
@router.get("/reset-password")
|
||||
def reset_password_check(token: str, request: Request):
|
||||
"""Pre-flight: validate that the token exists and isn't expired/used.
|
||||
Does NOT consume the token."""
|
||||
th = _hash_action_token(token)
|
||||
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email
|
||||
FROM pgz_sport.user_action_tokens t
|
||||
JOIN pgz_sport.users u ON u.id = t.user_id
|
||||
WHERE t.token_hash=%s AND t.kind='reset'""", (th,))
|
||||
if not row:
|
||||
raise HTTPException(404, "Token nije pronađen")
|
||||
if row["used_at"] is not None:
|
||||
raise HTTPException(410, "Token je već iskorišten")
|
||||
exp = row["expires_at"]
|
||||
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= _now():
|
||||
raise HTTPException(410, "Token je istekao")
|
||||
return {"status": "ok", "email": row["email"], "expires_at": row["expires_at"].isoformat()}
|
||||
|
||||
# ─────────────────────────── /auth/setup-password (invite) ───────────────────────────
|
||||
class SetupPwdReq(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@router.get("/setup-password")
|
||||
def setup_password_check(token: str, request: Request):
|
||||
"""Pre-flight: validate an invite token without consuming it."""
|
||||
th = _hash_action_token(token)
|
||||
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email, u.full_name, u.user_type
|
||||
FROM pgz_sport.user_action_tokens t
|
||||
JOIN pgz_sport.users u ON u.id = t.user_id
|
||||
WHERE t.token_hash=%s AND t.kind='invite'""", (th,))
|
||||
if not row:
|
||||
raise HTTPException(404, "Pozivnica nije pronađena")
|
||||
if row["used_at"] is not None:
|
||||
raise HTTPException(410, "Pozivnica je već iskorištena")
|
||||
exp = row["expires_at"]
|
||||
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= _now():
|
||||
raise HTTPException(410, "Pozivnica je istekla")
|
||||
return {"status": "ok",
|
||||
"email": row["email"],
|
||||
"full_name": row["full_name"],
|
||||
"user_type": row["user_type"],
|
||||
"expires_at": row["expires_at"].isoformat()}
|
||||
|
||||
@router.post("/setup-password")
|
||||
def setup_password_consume(req: SetupPwdReq, request: Request):
|
||||
"""Consume an invite token and set the user's first password."""
|
||||
if len(req.new_password or "") < 8:
|
||||
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
|
||||
row = consume_action_token(req.token, "invite")
|
||||
ip, ua = _client(request)
|
||||
if not row:
|
||||
audit(None, "invite.consume.fail",
|
||||
meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua)
|
||||
raise HTTPException(400, "Pozivnica je nevažeća ili istekla")
|
||||
if not row.get("aktivan") or row.get("status") != "active":
|
||||
audit(row["user_id"], "invite.consume.fail",
|
||||
meta={"reason": "user_inactive"}, ip=ip, ua=ua)
|
||||
raise HTTPException(403, "Račun nije aktivan")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=false,
|
||||
email_verified=true,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(req.new_password), row["user_id"]))
|
||||
audit(row["user_id"], "invite.consume.ok",
|
||||
meta={"email": row["email"]}, ip=ip, ua=ua)
|
||||
return {"status": "ok", "email": row["email"]}
|
||||
|
||||
# ─────────────────────────── 2FA — real TOTP (RFC 6238) ───────────────────────────
|
||||
try:
|
||||
import pyotp as _pyotp
|
||||
HAS_PYOTP = True
|
||||
except Exception:
|
||||
HAS_PYOTP = False
|
||||
|
||||
def _ensure_2fa_table():
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_2fa (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
secret TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT false,
|
||||
verified_at TIMESTAMPTZ,
|
||||
recovery_codes TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
)""")
|
||||
_ensure_2fa_table()
|
||||
|
||||
def _build_qr_png(otpauth_url: str) -> str:
|
||||
"""Return a data: URL containing a base64 PNG of the QR code."""
|
||||
try:
|
||||
import qrcode, io, base64
|
||||
img = qrcode.make(otpauth_url)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception as e:
|
||||
return ""
|
||||
|
||||
def _gen_recovery_codes(n: int = 8) -> List[str]:
|
||||
return [secrets.token_hex(4).upper() for _ in range(n)]
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
def twofa_setup(user = Depends(require_user)):
|
||||
"""Generate a TOTP secret, store unverified, and return otpauth URL + QR + recovery codes.
|
||||
The 2FA stays disabled until /2fa/verify confirms a valid TOTP code."""
|
||||
if not HAS_PYOTP:
|
||||
raise HTTPException(503, "pyotp not installed on server")
|
||||
secret = _pyotp.random_base32() # 32-char base32, RFC 4648 — what authenticator apps expect
|
||||
recovery = _gen_recovery_codes()
|
||||
db_exec("""INSERT INTO pgz_sport.user_2fa (user_id, secret, enabled, recovery_codes, updated_at)
|
||||
VALUES (%s,%s,false,%s,now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
secret=EXCLUDED.secret, enabled=false,
|
||||
recovery_codes=EXCLUDED.recovery_codes, updated_at=now()""",
|
||||
(user["id"], secret, recovery))
|
||||
issuer = "PGŽ Sport"
|
||||
otpauth = _pyotp.TOTP(secret).provisioning_uri(name=user["email"], issuer_name=issuer)
|
||||
return {
|
||||
"secret": secret,
|
||||
"otpauth_url": otpauth,
|
||||
"qr_png": _build_qr_png(otpauth),
|
||||
"issuer": issuer,
|
||||
"account": user["email"],
|
||||
"recovery_codes": recovery,
|
||||
"enabled": False,
|
||||
"instructions": "Skenirajte QR u Google Authenticator / Authy / 1Password, zatim potvrdite kod kroz POST /api/auth/2fa/verify",
|
||||
}
|
||||
|
||||
class TwoFAVerifyReq(BaseModel):
|
||||
code: str
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
def twofa_verify(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)):
|
||||
"""Verify TOTP code; on success, mark 2FA enabled."""
|
||||
if not HAS_PYOTP:
|
||||
raise HTTPException(503, "pyotp not installed on server")
|
||||
row = db_one("SELECT secret, enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||
(user["id"],))
|
||||
if not row:
|
||||
raise HTTPException(400, "2FA nije postavljen — pozovite /2fa/setup prvo")
|
||||
code = (req.code or "").strip().replace(" ", "")
|
||||
if not code or not code.isdigit() or len(code) not in (6, 8):
|
||||
raise HTTPException(400, "Neispravan format koda (6-8 znamenki)")
|
||||
totp = _pyotp.TOTP(row["secret"])
|
||||
# valid_window=1 → tolerate ±30s drift
|
||||
if not totp.verify(code, valid_window=1):
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "2fa.verify.fail", ip=ip, ua=ua)
|
||||
raise HTTPException(401, "Neispravan TOTP kod")
|
||||
db_exec("""UPDATE pgz_sport.user_2fa
|
||||
SET enabled=true, verified_at=now(), updated_at=now()
|
||||
WHERE user_id=%s""", (user["id"],))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "2fa.verify.ok", ip=ip, ua=ua)
|
||||
return {"status": "ok", "enabled": True}
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
def twofa_disable(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)):
|
||||
"""Disable 2FA — must verify a current TOTP code (or recovery code)."""
|
||||
if not HAS_PYOTP:
|
||||
raise HTTPException(503, "pyotp not installed on server")
|
||||
row = db_one("SELECT secret, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||
(user["id"],))
|
||||
if not row:
|
||||
raise HTTPException(404, "2FA nije postavljen")
|
||||
code = (req.code or "").strip().replace(" ", "").upper()
|
||||
valid = False
|
||||
if code.isdigit() and len(code) in (6, 8):
|
||||
valid = _pyotp.TOTP(row["secret"]).verify(code, valid_window=1)
|
||||
elif row.get("recovery_codes") and code in (row["recovery_codes"] or []):
|
||||
valid = True
|
||||
if not valid:
|
||||
raise HTTPException(401, "Neispravan kod")
|
||||
db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (user["id"],))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "2fa.disable", ip=ip, ua=ua)
|
||||
return {"status": "ok", "enabled": False}
|
||||
|
||||
@router.get("/2fa/status")
|
||||
def twofa_status(user = Depends(require_user)):
|
||||
row = db_one("SELECT enabled, verified_at, created_at FROM pgz_sport.user_2fa WHERE user_id=%s",
|
||||
(user["id"],))
|
||||
return {"enabled": bool(row and row.get("enabled")),
|
||||
"configured": bool(row),
|
||||
"verified_at": row.get("verified_at") if row else None}
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv('/opt/rinet-gpu/.env.master')
|
||||
# auto-added by patch_scrapers_with_dotenv.sh
|
||||
"""
|
||||
seal.py — Polygon PoS sealing module for PGŽ Sport audit log
|
||||
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
|
||||
@@ -34,7 +38,6 @@ list_seals(action=None, ref_type=None, ref_id=None, limit=50) -> list[dict]
|
||||
The module is import-safe even on hosts without web3 installed; the LIVE branch
|
||||
just becomes a no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
@@ -77,7 +80,7 @@ DB = dict(
|
||||
port=_pgp,
|
||||
dbname=os.environ.get("PG_DB", "rinet_v3"),
|
||||
user=os.environ.get("PG_USER", "rinet"),
|
||||
password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"),
|
||||
password=os.environ["DB_PASSWORD"],
|
||||
)
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
seal.py — Polygon PoS sealing module for PGŽ Sport audit log
|
||||
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
|
||||
Date: 2026-05-04
|
||||
Version: 1.0.0
|
||||
|
||||
Seals critical audit events to Polygon PoS (chain 137) using the wallet
|
||||
0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368.
|
||||
|
||||
Two operating modes:
|
||||
|
||||
1. LIVE — environment provides POLYGON_PRIVKEY (and web3 is installed).
|
||||
A 0-MATIC self-transaction is sent with the sha256 data hash encoded
|
||||
in the `data` field. Returns the real 0x… 64-char tx hash.
|
||||
|
||||
2. PENDING — no key configured. The seal record is queued in
|
||||
pgz_sport.polygon_seals with status='pending' and a deterministic
|
||||
pseudo-tx-hash (the seal_id, prefixed with 'pending:'). A later
|
||||
batch job (or operator) can flush the queue once a key is loaded.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
seal_to_polygon(data_hash, ref_id, action, **kw) -> dict
|
||||
Returns: { seal_id, tx_hash, status, polygonscan_url, ... }
|
||||
|
||||
verify_seal(seal_id) -> dict
|
||||
Read-back utility. Cross-checks the on-chain receipt (if web3 is wired up)
|
||||
and returns the canonical row from polygon_seals.
|
||||
|
||||
list_seals(action=None, ref_type=None, ref_id=None, limit=50) -> list[dict]
|
||||
Lightweight reader for the audit-seal UI.
|
||||
|
||||
The module is import-safe even on hosts without web3 installed; the LIVE branch
|
||||
just becomes a no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
# ─── Optional web3 dependency ────────────────────────────────────────────
|
||||
try:
|
||||
from web3 import Web3
|
||||
from eth_account import Account
|
||||
HAS_WEB3 = True
|
||||
except Exception:
|
||||
HAS_WEB3 = False
|
||||
|
||||
# ─── Configuration (env-driven) ──────────────────────────────────────────
|
||||
POLYGON_RPC = os.environ.get("POLYGON_RPC", "https://polygon-rpc.com")
|
||||
POLYGON_CHAIN_ID = int(os.environ.get("POLYGON_CHAIN_ID", "137"))
|
||||
POLYGON_WALLET = os.environ.get(
|
||||
"POLYGON_WALLET", "0xD874345dcB17baBDfbFac9bD7838AdE0D4a5d368"
|
||||
).strip()
|
||||
POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip()
|
||||
POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com")
|
||||
|
||||
_pgh = os.environ.get("PG_HOST", "10.10.0.2")
|
||||
_pgp = int(os.environ.get("PG_PORT", "6432"))
|
||||
# pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is
|
||||
# stale (local PG was decommissioned). Honour the DB_HOST/DB_PORT override that
|
||||
# points at canonical Server B (10.10.0.2:6432).
|
||||
if _pgh in ("localhost", "127.0.0.1"):
|
||||
_pgh = os.environ.get("DB_HOST", "10.10.0.2")
|
||||
_pgp = int(os.environ.get("DB_PORT", "6432"))
|
||||
|
||||
DB = dict(
|
||||
host=_pgh,
|
||||
port=_pgp,
|
||||
dbname=os.environ.get("PG_DB", "rinet_v3"),
|
||||
user=os.environ.get("PG_USER", "rinet"),
|
||||
password=os.environ["DB_PASSWORD"],
|
||||
)
|
||||
|
||||
# ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _db():
|
||||
c = psycopg2.connect(**DB)
|
||||
c.autocommit = True
|
||||
return c
|
||||
|
||||
|
||||
def _sha256(*parts: Any) -> str:
|
||||
h = hashlib.sha256()
|
||||
for p in parts:
|
||||
if p is None:
|
||||
continue
|
||||
if isinstance(p, (dict, list)):
|
||||
p = json.dumps(p, sort_keys=True, ensure_ascii=False, default=str)
|
||||
h.update(str(p).encode("utf-8", errors="replace"))
|
||||
h.update(b"\x00")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def hash_payload(payload: Any) -> str:
|
||||
"""Public helper — stable sha256 of a payload, JSON-canonicalised."""
|
||||
if isinstance(payload, (dict, list)):
|
||||
payload = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
|
||||
return hashlib.sha256(str(payload).encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
def polygonscan_url(tx_hash: str) -> Optional[str]:
|
||||
if not tx_hash or tx_hash.startswith("pending:"):
|
||||
return None
|
||||
if not tx_hash.startswith("0x"):
|
||||
tx_hash = "0x" + tx_hash
|
||||
return f"{POLYGONSCAN_BASE}/tx/{tx_hash}"
|
||||
|
||||
|
||||
# ─── live broadcast path ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _broadcast_live(data_hash: str, action: str, ref_id: str) -> dict:
|
||||
"""Send a 0-MATIC self-tx encoding `data_hash` in the data field.
|
||||
|
||||
Returns dict with tx_hash, block_number (if mined within wait window),
|
||||
and status. Raises on RPC errors so the caller can fall back.
|
||||
"""
|
||||
if not HAS_WEB3:
|
||||
raise RuntimeError("web3 not installed")
|
||||
if not POLYGON_PRIVKEY:
|
||||
raise RuntimeError("POLYGON_PRIVKEY missing")
|
||||
|
||||
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 15}))
|
||||
acct = Account.from_key(POLYGON_PRIVKEY)
|
||||
|
||||
if acct.address.lower() != POLYGON_WALLET.lower():
|
||||
raise RuntimeError(
|
||||
f"key/address mismatch: key={acct.address} wallet={POLYGON_WALLET}"
|
||||
)
|
||||
|
||||
nonce = w3.eth.get_transaction_count(acct.address)
|
||||
gas_price = w3.eth.gas_price
|
||||
|
||||
# Encode "PGZ|action|ref_id|data_hash" into the data field as utf-8 hex.
|
||||
memo = f"PGZ|{action}|{ref_id}|0x{data_hash}".encode("utf-8")
|
||||
tx = {
|
||||
"to": acct.address,
|
||||
"value": 0,
|
||||
"data": "0x" + memo.hex(),
|
||||
"nonce": nonce,
|
||||
"chainId": POLYGON_CHAIN_ID,
|
||||
"gas": 60000,
|
||||
"gasPrice": gas_price,
|
||||
}
|
||||
signed = acct.sign_transaction(tx)
|
||||
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction).hex()
|
||||
|
||||
block_number = None
|
||||
try:
|
||||
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=30)
|
||||
block_number = int(receipt.blockNumber)
|
||||
status = "confirmed" if receipt.status == 1 else "failed"
|
||||
except Exception:
|
||||
status = "broadcast"
|
||||
|
||||
return {"tx_hash": tx_hash, "block_number": block_number, "status": status}
|
||||
|
||||
|
||||
# ─── public API ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def seal_to_polygon(
|
||||
data_hash: str,
|
||||
ref_id: str,
|
||||
action: str,
|
||||
*,
|
||||
ref_type: Optional[str] = None,
|
||||
payload: Optional[Any] = None,
|
||||
user_id: Optional[int] = None,
|
||||
user_email: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Seal a sha256 hash to Polygon PoS.
|
||||
|
||||
Always persists a row in pgz_sport.polygon_seals. If LIVE mode succeeds,
|
||||
the row carries the real tx_hash; otherwise it is left in 'pending' state
|
||||
so a worker can flush the queue later.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data_hash : str
|
||||
sha256 hex digest of the payload being sealed.
|
||||
ref_id : str
|
||||
opaque reference (e.g. "klub:42", "sufinanciranje:2026-001").
|
||||
action : str
|
||||
canonical action name (e.g. "sufinanciranje.approved").
|
||||
"""
|
||||
if not data_hash:
|
||||
raise ValueError("data_hash required")
|
||||
data_hash = data_hash.lower().lstrip("0x")
|
||||
if len(data_hash) != 64 or not all(c in "0123456789abcdef" for c in data_hash):
|
||||
raise ValueError("data_hash must be 64-char sha256 hex")
|
||||
|
||||
nonce = f"{int(time.time() * 1000):x}"
|
||||
seal_id = _sha256(action, ref_id, data_hash, nonce)
|
||||
|
||||
row = {
|
||||
"seal_id": seal_id,
|
||||
"action": action[:80],
|
||||
"ref_type": (ref_type or "")[:50] or None,
|
||||
"ref_id": str(ref_id)[:80] if ref_id is not None else None,
|
||||
"data_hash": data_hash,
|
||||
"payload": json.dumps(payload, default=str) if payload is not None else None,
|
||||
"wallet": POLYGON_WALLET,
|
||||
"chain_id": POLYGON_CHAIN_ID,
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
}
|
||||
|
||||
tx_hash: Optional[str] = None
|
||||
block_number: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
status = "pending"
|
||||
|
||||
if HAS_WEB3 and POLYGON_PRIVKEY:
|
||||
try:
|
||||
r = _broadcast_live(data_hash, action, str(ref_id))
|
||||
tx_hash = r["tx_hash"]
|
||||
block_number = r.get("block_number")
|
||||
status = r.get("status", "broadcast")
|
||||
except Exception as e:
|
||||
error = f"{type(e).__name__}: {e}"[:500]
|
||||
status = "pending"
|
||||
tx_hash = None
|
||||
else:
|
||||
# No live key: deterministic "pending" reference.
|
||||
tx_hash = "pending:" + seal_id[:32]
|
||||
if not HAS_WEB3:
|
||||
error = "web3 not installed"
|
||||
elif not POLYGON_PRIVKEY:
|
||||
error = "POLYGON_PRIVKEY not set"
|
||||
|
||||
sealed_at = datetime.now(timezone.utc) if status in ("broadcast", "confirmed") else None
|
||||
|
||||
with _db() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pgz_sport.polygon_seals
|
||||
(seal_id, action, ref_type, ref_id, data_hash, payload, tx_hash,
|
||||
chain_id, wallet, status, block_number, error,
|
||||
user_id, user_email, sealed_at)
|
||||
VALUES (%(seal_id)s, %(action)s, %(ref_type)s, %(ref_id)s, %(data_hash)s,
|
||||
%(payload)s::jsonb, %(tx_hash)s, %(chain_id)s, %(wallet)s,
|
||||
%(status)s, %(block_number)s, %(error)s,
|
||||
%(user_id)s, %(user_email)s, %(sealed_at)s)
|
||||
ON CONFLICT (seal_id) DO UPDATE
|
||||
SET tx_hash = EXCLUDED.tx_hash,
|
||||
status = EXCLUDED.status,
|
||||
block_number = EXCLUDED.block_number,
|
||||
error = EXCLUDED.error,
|
||||
sealed_at = EXCLUDED.sealed_at
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
{
|
||||
**row,
|
||||
"tx_hash": tx_hash,
|
||||
"status": status,
|
||||
"block_number": block_number,
|
||||
"error": error,
|
||||
"sealed_at": sealed_at,
|
||||
},
|
||||
)
|
||||
rid, created_at = cur.fetchone()
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"seal_id": seal_id,
|
||||
"action": action,
|
||||
"ref_type": ref_type,
|
||||
"ref_id": ref_id,
|
||||
"data_hash": data_hash,
|
||||
"tx_hash": tx_hash,
|
||||
"status": status,
|
||||
"block_number": block_number,
|
||||
"wallet": POLYGON_WALLET,
|
||||
"chain_id": POLYGON_CHAIN_ID,
|
||||
"polygonscan_url": polygonscan_url(tx_hash),
|
||||
"error": error,
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
"live": HAS_WEB3 and bool(POLYGON_PRIVKEY),
|
||||
}
|
||||
|
||||
|
||||
def verify_seal(seal_id: str) -> Optional[dict]:
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash,
|
||||
chain_id, wallet, status, block_number, error,
|
||||
user_id, user_email, created_at, sealed_at, payload
|
||||
FROM pgz_sport.polygon_seals WHERE seal_id=%s""",
|
||||
(seal_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
row = dict(row)
|
||||
row["polygonscan_url"] = polygonscan_url(row.get("tx_hash"))
|
||||
if row.get("created_at"):
|
||||
row["created_at"] = row["created_at"].isoformat()
|
||||
if row.get("sealed_at"):
|
||||
row["sealed_at"] = row["sealed_at"].isoformat()
|
||||
|
||||
if HAS_WEB3 and row.get("tx_hash") and not str(row["tx_hash"]).startswith("pending:"):
|
||||
try:
|
||||
w3 = Web3(Web3.HTTPProvider(POLYGON_RPC, request_kwargs={"timeout": 8}))
|
||||
r = w3.eth.get_transaction_receipt(row["tx_hash"])
|
||||
row["onchain"] = {
|
||||
"block_number": int(r.blockNumber),
|
||||
"status": int(r.status),
|
||||
"from": r["from"],
|
||||
"to": r["to"],
|
||||
}
|
||||
except Exception as e:
|
||||
row["onchain"] = {"error": str(e)[:200]}
|
||||
return row
|
||||
|
||||
|
||||
def list_seals(
|
||||
action: Optional[str] = None,
|
||||
ref_type: Optional[str] = None,
|
||||
ref_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
where, params = [], []
|
||||
if action:
|
||||
where.append("action = %s")
|
||||
params.append(action)
|
||||
if ref_type:
|
||||
where.append("ref_type = %s")
|
||||
params.append(ref_type)
|
||||
if ref_id is not None:
|
||||
where.append("ref_id = %s")
|
||||
params.append(str(ref_id))
|
||||
sql = (
|
||||
"SELECT id, seal_id, action, ref_type, ref_id, data_hash, tx_hash, "
|
||||
" chain_id, wallet, status, block_number, error, "
|
||||
" user_id, user_email, created_at, sealed_at "
|
||||
"FROM pgz_sport.polygon_seals "
|
||||
+ ("WHERE " + " AND ".join(where) + " " if where else "")
|
||||
+ "ORDER BY id DESC LIMIT %s"
|
||||
)
|
||||
params.append(min(int(limit or 50), 500))
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
for r in rows:
|
||||
r["polygonscan_url"] = polygonscan_url(r.get("tx_hash"))
|
||||
if r.get("created_at"):
|
||||
r["created_at"] = r["created_at"].isoformat()
|
||||
if r.get("sealed_at"):
|
||||
r["sealed_at"] = r["sealed_at"].isoformat()
|
||||
return rows
|
||||
|
||||
|
||||
# ─── self-test ───────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
payload = {"demo": True, "ts": int(time.time()), "msg": "PGŽ seal self-test"}
|
||||
h = hash_payload(payload)
|
||||
res = seal_to_polygon(
|
||||
h,
|
||||
ref_id="selftest:1",
|
||||
action="selftest.run",
|
||||
ref_type="selftest",
|
||||
payload=payload,
|
||||
)
|
||||
print(json.dumps(res, indent=2, default=str, ensure_ascii=False))
|
||||
@@ -0,0 +1 @@
|
||||
{"version":"1.0","provider_name":"Rijecki sportski savez","provider_url":"https:\/\/rss.hr","author_name":"Slavisa Bradic","author_url":"https:\/\/rss.hr\/author\/slavisa-bradic\/","title":"Svjetska liga u umjetni\u010dkom plivanju","type":"rich","width":600,"height":338,"html":"<blockquote class=\"wp-embedded-content\" data-secret=\"T58xsoqCLk\"><a href=\"https:\/\/rss.hr\/svjetska-liga-u-umjetnickom-plivanju\/\">Svjetska liga u umjetni\u010dkom plivanju<\/a><\/blockquote><iframe sandbox=\"allow-scripts\" security=\"restricted\" src=\"https:\/\/rss.hr\/svjetska-liga-u-umjetnickom-plivanju\/embed\/#?secret=T58xsoqCLk\" width=\"600\" height=\"338\" title=\"“Svjetska liga u umjetni\u010dkom plivanju” — Rijecki sportski savez\" data-secret=\"T58xsoqCLk\" frameborder=\"0\" marginwidth=\"0\" marginheight=\"0\" scrolling=\"no\" class=\"wp-embedded-content\"><\/iframe><script>\n\/*! This file is auto-generated *\/\n!function(d,l){\"use strict\";l.querySelector&&d.addEventListener&&\"undefined\"!=typeof URL&&(d.wp=d.wp||{},d.wp.receiveEmbedMessage||(d.wp.receiveEmbedMessage=function(e){var t=e.data;if((t||t.secret||t.message||t.value)&&!\/[^a-zA-Z0-9]\/.test(t.secret)){for(var s,r,n,a=l.querySelectorAll('iframe[data-secret=\"'+t.secret+'\"]'),o=l.querySelectorAll('blockquote[data-secret=\"'+t.secret+'\"]'),c=new RegExp(\"^https?:$\",\"i\"),i=0;i<o.length;i++)o[i].style.display=\"none\";for(i=0;i<a.length;i++)s=a[i],e.source===s.contentWindow&&(s.removeAttribute(\"style\"),\"height\"===t.message?(1e3<(r=parseInt(t.value,10))?r=1e3:~~r<200&&(r=200),s.height=r):\"link\"===t.message&&(r=new URL(s.getAttribute(\"src\")),n=new URL(t.value),c.test(n.protocol))&&n.host===r.host&&l.activeElement===s&&(d.top.location.href=t.value))}},d.addEventListener(\"message\",d.wp.receiveEmbedMessage,!1),l.addEventListener(\"DOMContentLoaded\",function(){for(var e,t,s=l.querySelectorAll(\"iframe.wp-embedded-content\"),r=0;r<s.length;r++)(t=(e=s[r]).getAttribute(\"data-secret\"))||(t=Math.random().toString(36).substring(2,12),e.src+=\"#?secret=\"+t,e.setAttribute(\"data-secret\",t)),e.contentWindow.postMessage({message:\"ready\",secret:t},\"*\")},!1)))}(window,document);\n\/\/# sourceURL=https:\/\/rss.hr\/wp-includes\/js\/wp-embed.min.js\n<\/script>\n","thumbnail_url":"https:\/\/rss.hr\/wp-content\/uploads\/2024\/12\/IMG-20241216-WA00061-768x512.jpg","thumbnail_width":600,"thumbnail_height":400}
|
||||
@@ -0,0 +1,434 @@
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.events-list .event {
|
||||
border-bottom: 1px solid #5e5e5e;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.events-list .event .date {
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
.events-list .event .date .day,
|
||||
.events-list .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-list .event .date .day {
|
||||
font-size: 2.1875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.events-list .event .date .month {}
|
||||
|
||||
.events-list .event .title {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.events-list .event .description {
|
||||
margin: .5rem auto;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.events-list .event .images {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.events-list .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-list .event .event-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.events-grid .event {
|
||||
float: left;
|
||||
width: 31.33%;
|
||||
margin: 1% 0 1% 3%;
|
||||
border: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.events-grid .event:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.events-grid .event:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.events-grid .event .images {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-grid .event .images .date {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
color: #ffffff;
|
||||
background-color: red;
|
||||
padding: .8rem 1.5rem;
|
||||
}
|
||||
|
||||
.events-grid .event .date .day {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content .title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content p {
|
||||
font-size: .875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.events-grid .event .buttons .btn-cta {
|
||||
background-color: red;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
padding: .8rem 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.events-grid .event {
|
||||
width: 48.5%;
|
||||
margin: 1% 0 1% 3%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.events-grid .event {
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0 auto 3%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************\
|
||||
Basic Modal Styles
|
||||
\**************************/
|
||||
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
width: 900px;
|
||||
max-width: 90%;
|
||||
max-height: 100vh;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modal__header .modal__close:before {
|
||||
content: "\2715";
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
.modal__btn {
|
||||
font-size: .875rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: .5rem;
|
||||
padding-bottom: .5rem;
|
||||
background-color: #e6e6e6;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
border-radius: .25rem;
|
||||
border-style: none;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: button;
|
||||
text-transform: none;
|
||||
overflow: visible;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
will-change: transform;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
transition: -webkit-transform .25s ease-out;
|
||||
transition: transform .25s ease-out;
|
||||
transition: transform .25s ease-out, -webkit-transform .25s ease-out;
|
||||
}
|
||||
|
||||
.modal__btn:focus,
|
||||
.modal__btn:hover {
|
||||
-webkit-transform: scale(1.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal__btn-primary {
|
||||
background-color: #00449e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**************************\
|
||||
Demo Animation Style
|
||||
\**************************/
|
||||
@keyframes mmfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.micromodal-slide.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
.micromodal-slide .modal__overlay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.micromodal-slide .title {
|
||||
font-size: 1.5625rem;
|
||||
color: #111111;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.micromodal-slide .images {
|
||||
margin: .5rem auto 1.5rem;
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
-ms-border-radius: 10px;
|
||||
-o-border-radius: 10px;
|
||||
}
|
||||
|
||||
.micromodal-slide .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.micromodal-slide h2.title {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide h4.title {
|
||||
font-size: 1rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide .short_description {
|
||||
font-size: 1.125rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
.micromodal-slide .long_description {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period {
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td strong {
|
||||
display: block;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr {
|
||||
border-top: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td {
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
vertical-align: top;
|
||||
padding: 1rem .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder td {
|
||||
padding: 0 .5rem 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag {
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #ffffff;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag.canceled {
|
||||
background-color: #FF0000;
|
||||
}
|
||||
.micromodal-slide span.tag.free-entry {
|
||||
background-color: #28A745;
|
||||
}
|
||||
.micromodal-slide span.tag.limited {
|
||||
background-color: #1668B2;
|
||||
}
|
||||
|
||||
.micromodal-slide .tags {
|
||||
margin-right: 1rem;
|
||||
background-color: #f2f2f2;
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #111111;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.events-list .event {
|
||||
border-bottom: 1px solid #5e5e5e;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.events-list .event .date {
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
.events-list .event .date .day,
|
||||
.events-list .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-list .event .date .day {
|
||||
font-size: 2.1875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.events-list .event .date .month {}
|
||||
|
||||
.events-list .event .title {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.events-list .event .description {
|
||||
margin: .5rem auto;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.events-list .event .images {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.events-list .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-list .event .event-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.events-grid .event {
|
||||
float: left;
|
||||
width: 31.33%;
|
||||
margin: 1% 0 1% 3%;
|
||||
border: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.events-grid .event:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.events-grid .event:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.events-grid .event .images {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-grid .event .images .date {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
color: #ffffff;
|
||||
background-color: red;
|
||||
padding: .8rem 1.5rem;
|
||||
}
|
||||
|
||||
.events-grid .event .date .day {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content .title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content p {
|
||||
font-size: .875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.events-grid .event .buttons .btn-cta {
|
||||
background-color: red;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
padding: .8rem 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.events-grid .event {
|
||||
width: 48.5%;
|
||||
margin: 1% 0 1% 3%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.events-grid .event {
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0 auto 3%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************\
|
||||
Basic Modal Styles
|
||||
\**************************/
|
||||
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
width: 900px;
|
||||
max-width: 90%;
|
||||
max-height: 100vh;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modal__header .modal__close:before {
|
||||
content: "\2715";
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
.modal__btn {
|
||||
font-size: .875rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: .5rem;
|
||||
padding-bottom: .5rem;
|
||||
background-color: #e6e6e6;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
border-radius: .25rem;
|
||||
border-style: none;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: button;
|
||||
text-transform: none;
|
||||
overflow: visible;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
will-change: transform;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
transition: -webkit-transform .25s ease-out;
|
||||
transition: transform .25s ease-out;
|
||||
transition: transform .25s ease-out, -webkit-transform .25s ease-out;
|
||||
}
|
||||
|
||||
.modal__btn:focus,
|
||||
.modal__btn:hover {
|
||||
-webkit-transform: scale(1.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal__btn-primary {
|
||||
background-color: #00449e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**************************\
|
||||
Demo Animation Style
|
||||
\**************************/
|
||||
@keyframes mmfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.micromodal-slide.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
.micromodal-slide .modal__overlay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.micromodal-slide .title {
|
||||
font-size: 1.5625rem;
|
||||
color: #111111;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.micromodal-slide .images {
|
||||
margin: .5rem auto 1.5rem;
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
-ms-border-radius: 10px;
|
||||
-o-border-radius: 10px;
|
||||
}
|
||||
|
||||
.micromodal-slide .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.micromodal-slide h2.title {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide h4.title {
|
||||
font-size: 1rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide .short_description {
|
||||
font-size: 1.125rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
.micromodal-slide .long_description {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period {
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td strong {
|
||||
display: block;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr {
|
||||
border-top: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td {
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
vertical-align: top;
|
||||
padding: 1rem .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder td {
|
||||
padding: 0 .5rem 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag {
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #ffffff;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag.canceled {
|
||||
background-color: #FF0000;
|
||||
}
|
||||
.micromodal-slide span.tag.free-entry {
|
||||
background-color: #28A745;
|
||||
}
|
||||
.micromodal-slide span.tag.limited {
|
||||
background-color: #1668B2;
|
||||
}
|
||||
|
||||
.micromodal-slide .tags {
|
||||
margin-right: 1rem;
|
||||
background-color: #f2f2f2;
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #111111;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.elementor-widget-divider{--divider-border-style:none;--divider-border-width:1px;--divider-color:#0c0d0e;--divider-icon-size:20px;--divider-element-spacing:10px;--divider-pattern-height:24px;--divider-pattern-size:20px;--divider-pattern-url:none;--divider-pattern-repeat:repeat-x}.elementor-widget-divider .elementor-divider{display:flex}.elementor-widget-divider .elementor-divider__text{font-size:15px;line-height:1;max-width:95%}.elementor-widget-divider .elementor-divider__element{flex-shrink:0;margin:0 var(--divider-element-spacing)}.elementor-widget-divider .elementor-icon{font-size:var(--divider-icon-size)}.elementor-widget-divider .elementor-divider-separator{direction:ltr;display:flex;margin:0}.elementor-widget-divider--view-line_icon .elementor-divider-separator,.elementor-widget-divider--view-line_text .elementor-divider-separator{align-items:center}.elementor-widget-divider--view-line_icon .elementor-divider-separator:after,.elementor-widget-divider--view-line_icon .elementor-divider-separator:before,.elementor-widget-divider--view-line_text .elementor-divider-separator:after,.elementor-widget-divider--view-line_text .elementor-divider-separator:before{border-block-end:0;border-block-start:var(--divider-border-width) var(--divider-border-style) var(--divider-color);content:"";display:block;flex-grow:1}.elementor-widget-divider--element-align-left .elementor-divider .elementor-divider-separator>.elementor-divider__svg:first-of-type{flex-grow:0;flex-shrink:100}.elementor-widget-divider--element-align-left .elementor-divider-separator:before{content:none}.elementor-widget-divider--element-align-left .elementor-divider__element{margin-left:0}.elementor-widget-divider--element-align-right .elementor-divider .elementor-divider-separator>.elementor-divider__svg:last-of-type{flex-grow:0;flex-shrink:100}.elementor-widget-divider--element-align-right .elementor-divider-separator:after{content:none}.elementor-widget-divider--element-align-right .elementor-divider__element{margin-right:0}.elementor-widget-divider--element-align-start .elementor-divider .elementor-divider-separator>.elementor-divider__svg:first-of-type{flex-grow:0;flex-shrink:100}.elementor-widget-divider--element-align-start .elementor-divider-separator:before{content:none}.elementor-widget-divider--element-align-start .elementor-divider__element{margin-inline-start:0}.elementor-widget-divider--element-align-end .elementor-divider .elementor-divider-separator>.elementor-divider__svg:last-of-type{flex-grow:0;flex-shrink:100}.elementor-widget-divider--element-align-end .elementor-divider-separator:after{content:none}.elementor-widget-divider--element-align-end .elementor-divider__element{margin-inline-end:0}.elementor-widget-divider:not(.elementor-widget-divider--view-line_text):not(.elementor-widget-divider--view-line_icon) .elementor-divider-separator{border-block-start:var(--divider-border-width) var(--divider-border-style) var(--divider-color)}.elementor-widget-divider--separator-type-pattern{--divider-border-style:none}.elementor-widget-divider--separator-type-pattern.elementor-widget-divider--view-line .elementor-divider-separator,.elementor-widget-divider--separator-type-pattern:not(.elementor-widget-divider--view-line) .elementor-divider-separator:after,.elementor-widget-divider--separator-type-pattern:not(.elementor-widget-divider--view-line) .elementor-divider-separator:before,.elementor-widget-divider--separator-type-pattern:not([class*=elementor-widget-divider--view]) .elementor-divider-separator{background-color:var(--divider-color);-webkit-mask-image:var(--divider-pattern-url);mask-image:var(--divider-pattern-url);-webkit-mask-repeat:var(--divider-pattern-repeat);mask-repeat:var(--divider-pattern-repeat);-webkit-mask-size:var(--divider-pattern-size) 100%;mask-size:var(--divider-pattern-size) 100%;min-height:var(--divider-pattern-height);width:100%}.elementor-widget-divider--no-spacing{--divider-pattern-size:auto}.elementor-widget-divider--bg-round{--divider-pattern-repeat:round}.rtl .elementor-widget-divider .elementor-divider__text{direction:rtl}.e-con-inner>.elementor-widget-divider,.e-con>.elementor-widget-divider{width:var(--container-widget-width,100%);--flex-grow:var( --container-widget-flex-grow )}
|
||||
@@ -0,0 +1,434 @@
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.events-list .event {
|
||||
border-bottom: 1px solid #5e5e5e;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.events-list .event .date {
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
.events-list .event .date .day,
|
||||
.events-list .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-list .event .date .day {
|
||||
font-size: 2.1875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.events-list .event .date .month {}
|
||||
|
||||
.events-list .event .title {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.events-list .event .description {
|
||||
margin: .5rem auto;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.events-list .event .images {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.events-list .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-list .event .event-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.events-grid .event {
|
||||
float: left;
|
||||
width: 31.33%;
|
||||
margin: 1% 0 1% 3%;
|
||||
border: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.events-grid .event:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.events-grid .event:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.events-grid .event .images {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-grid .event .images .date {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
color: #ffffff;
|
||||
background-color: red;
|
||||
padding: .8rem 1.5rem;
|
||||
}
|
||||
|
||||
.events-grid .event .date .day {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content .title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content p {
|
||||
font-size: .875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.events-grid .event .buttons .btn-cta {
|
||||
background-color: red;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
padding: .8rem 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.events-grid .event {
|
||||
width: 48.5%;
|
||||
margin: 1% 0 1% 3%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.events-grid .event {
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0 auto 3%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************\
|
||||
Basic Modal Styles
|
||||
\**************************/
|
||||
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
width: 900px;
|
||||
max-width: 90%;
|
||||
max-height: 100vh;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modal__header .modal__close:before {
|
||||
content: "\2715";
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
.modal__btn {
|
||||
font-size: .875rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: .5rem;
|
||||
padding-bottom: .5rem;
|
||||
background-color: #e6e6e6;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
border-radius: .25rem;
|
||||
border-style: none;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: button;
|
||||
text-transform: none;
|
||||
overflow: visible;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
will-change: transform;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
transition: -webkit-transform .25s ease-out;
|
||||
transition: transform .25s ease-out;
|
||||
transition: transform .25s ease-out, -webkit-transform .25s ease-out;
|
||||
}
|
||||
|
||||
.modal__btn:focus,
|
||||
.modal__btn:hover {
|
||||
-webkit-transform: scale(1.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal__btn-primary {
|
||||
background-color: #00449e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**************************\
|
||||
Demo Animation Style
|
||||
\**************************/
|
||||
@keyframes mmfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.micromodal-slide.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
.micromodal-slide .modal__overlay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.micromodal-slide .title {
|
||||
font-size: 1.5625rem;
|
||||
color: #111111;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.micromodal-slide .images {
|
||||
margin: .5rem auto 1.5rem;
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
-ms-border-radius: 10px;
|
||||
-o-border-radius: 10px;
|
||||
}
|
||||
|
||||
.micromodal-slide .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.micromodal-slide h2.title {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide h4.title {
|
||||
font-size: 1rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide .short_description {
|
||||
font-size: 1.125rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
.micromodal-slide .long_description {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period {
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td strong {
|
||||
display: block;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr {
|
||||
border-top: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td {
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
vertical-align: top;
|
||||
padding: 1rem .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder td {
|
||||
padding: 0 .5rem 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag {
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #ffffff;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag.canceled {
|
||||
background-color: #FF0000;
|
||||
}
|
||||
.micromodal-slide span.tag.free-entry {
|
||||
background-color: #28A745;
|
||||
}
|
||||
.micromodal-slide span.tag.limited {
|
||||
background-color: #1668B2;
|
||||
}
|
||||
|
||||
.micromodal-slide .tags {
|
||||
margin-right: 1rem;
|
||||
background-color: #f2f2f2;
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #111111;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.events-list .event {
|
||||
border-bottom: 1px solid #5e5e5e;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.events-list .event .date {
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
.events-list .event .date .day,
|
||||
.events-list .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-list .event .date .day {
|
||||
font-size: 2.1875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.events-list .event .date .month {}
|
||||
|
||||
.events-list .event .title {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.events-list .event .description {
|
||||
margin: .5rem auto;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.events-list .event .images {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.events-list .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-list .event .event-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.events-grid .event {
|
||||
float: left;
|
||||
width: 31.33%;
|
||||
margin: 1% 0 1% 3%;
|
||||
border: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.events-grid .event:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.events-grid .event:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.events-grid .event .images {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-grid .event .images .date {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
color: #ffffff;
|
||||
background-color: red;
|
||||
padding: .8rem 1.5rem;
|
||||
}
|
||||
|
||||
.events-grid .event .date .day {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content .title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content p {
|
||||
font-size: .875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.events-grid .event .buttons .btn-cta {
|
||||
background-color: red;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
padding: .8rem 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.events-grid .event {
|
||||
width: 48.5%;
|
||||
margin: 1% 0 1% 3%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.events-grid .event {
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0 auto 3%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************\
|
||||
Basic Modal Styles
|
||||
\**************************/
|
||||
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
width: 900px;
|
||||
max-width: 90%;
|
||||
max-height: 100vh;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modal__header .modal__close:before {
|
||||
content: "\2715";
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
.modal__btn {
|
||||
font-size: .875rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: .5rem;
|
||||
padding-bottom: .5rem;
|
||||
background-color: #e6e6e6;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
border-radius: .25rem;
|
||||
border-style: none;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: button;
|
||||
text-transform: none;
|
||||
overflow: visible;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
will-change: transform;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
transition: -webkit-transform .25s ease-out;
|
||||
transition: transform .25s ease-out;
|
||||
transition: transform .25s ease-out, -webkit-transform .25s ease-out;
|
||||
}
|
||||
|
||||
.modal__btn:focus,
|
||||
.modal__btn:hover {
|
||||
-webkit-transform: scale(1.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal__btn-primary {
|
||||
background-color: #00449e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**************************\
|
||||
Demo Animation Style
|
||||
\**************************/
|
||||
@keyframes mmfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.micromodal-slide.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
.micromodal-slide .modal__overlay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.micromodal-slide .title {
|
||||
font-size: 1.5625rem;
|
||||
color: #111111;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.micromodal-slide .images {
|
||||
margin: .5rem auto 1.5rem;
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
-ms-border-radius: 10px;
|
||||
-o-border-radius: 10px;
|
||||
}
|
||||
|
||||
.micromodal-slide .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.micromodal-slide h2.title {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide h4.title {
|
||||
font-size: 1rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide .short_description {
|
||||
font-size: 1.125rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
.micromodal-slide .long_description {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period {
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td strong {
|
||||
display: block;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr {
|
||||
border-top: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td {
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
vertical-align: top;
|
||||
padding: 1rem .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder td {
|
||||
padding: 0 .5rem 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag {
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #ffffff;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag.canceled {
|
||||
background-color: #FF0000;
|
||||
}
|
||||
.micromodal-slide span.tag.free-entry {
|
||||
background-color: #28A745;
|
||||
}
|
||||
.micromodal-slide span.tag.limited {
|
||||
background-color: #1668B2;
|
||||
}
|
||||
|
||||
.micromodal-slide .tags {
|
||||
margin-right: 1rem;
|
||||
background-color: #f2f2f2;
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #111111;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
.events-list {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.events-list .event {
|
||||
border-bottom: 1px solid #5e5e5e;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.events-list .event .date {
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
.events-list .event .date .day,
|
||||
.events-list .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-list .event .date .day {
|
||||
font-size: 2.1875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.events-list .event .date .month {}
|
||||
|
||||
.events-list .event .title {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.events-list .event .description {
|
||||
margin: .5rem auto;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.events-list .event .images {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.events-list .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-list .event .event-info {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.events-grid .event {
|
||||
float: left;
|
||||
width: 31.33%;
|
||||
margin: 1% 0 1% 3%;
|
||||
border: none !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.events-grid .event:after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.events-grid .event:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.events-grid .event .images {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-grid .event .images .date {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
color: #ffffff;
|
||||
background-color: red;
|
||||
padding: .8rem 1.5rem;
|
||||
}
|
||||
|
||||
.events-grid .event .date .day {
|
||||
font-size: 1.875rem;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .date .month {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.events-grid .event .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content .title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.3;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.events-grid .event .event-content p {
|
||||
font-size: .875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.events-grid .event .buttons .btn-cta {
|
||||
background-color: red;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
padding: .8rem 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.events-grid .event {
|
||||
width: 48.5%;
|
||||
margin: 1% 0 1% 3%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.events-grid .event {
|
||||
float: none;
|
||||
width: 100%;
|
||||
margin: 0 auto 3%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************\
|
||||
Basic Modal Styles
|
||||
\**************************/
|
||||
|
||||
.modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.modal__container {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
width: 900px;
|
||||
max-width: 90%;
|
||||
max-height: 100vh;
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
color: #000000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modal__header .modal__close:before {
|
||||
content: "\2715";
|
||||
}
|
||||
|
||||
.modal__content {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
.modal__btn {
|
||||
font-size: .875rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: .5rem;
|
||||
padding-bottom: .5rem;
|
||||
background-color: #e6e6e6;
|
||||
color: rgba(0, 0, 0, .8);
|
||||
border-radius: .25rem;
|
||||
border-style: none;
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: button;
|
||||
text-transform: none;
|
||||
overflow: visible;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
will-change: transform;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
transition: -webkit-transform .25s ease-out;
|
||||
transition: transform .25s ease-out;
|
||||
transition: transform .25s ease-out, -webkit-transform .25s ease-out;
|
||||
}
|
||||
|
||||
.modal__btn:focus,
|
||||
.modal__btn:hover {
|
||||
-webkit-transform: scale(1.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal__btn-primary {
|
||||
background-color: #00449e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**************************\
|
||||
Demo Animation Style
|
||||
\**************************/
|
||||
@keyframes mmfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.micromodal-slide.is-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
.micromodal-slide .modal__overlay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.micromodal-slide .title {
|
||||
font-size: 1.5625rem;
|
||||
color: #111111;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.micromodal-slide .images {
|
||||
margin: .5rem auto 1.5rem;
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
-webkit-border-radius: 10px;
|
||||
-moz-border-radius: 10px;
|
||||
-ms-border-radius: 10px;
|
||||
-o-border-radius: 10px;
|
||||
}
|
||||
|
||||
.micromodal-slide .images img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.micromodal-slide h2.title {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide h4.title {
|
||||
font-size: 1rem;
|
||||
margin: 0 auto .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide .short_description {
|
||||
font-size: 1.125rem;
|
||||
font-weight: lighter;
|
||||
}
|
||||
.micromodal-slide .long_description {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period {
|
||||
border: none;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td strong {
|
||||
display: block;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr {
|
||||
border-top: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period td {
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
vertical-align: top;
|
||||
padding: 1rem .5rem;
|
||||
}
|
||||
|
||||
.micromodal-slide table.period tr.noborder td {
|
||||
padding: 0 .5rem 1rem;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag {
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #ffffff;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
|
||||
.micromodal-slide span.tag.canceled {
|
||||
background-color: #FF0000;
|
||||
}
|
||||
.micromodal-slide span.tag.free-entry {
|
||||
background-color: #28A745;
|
||||
}
|
||||
.micromodal-slide span.tag.limited {
|
||||
background-color: #1668B2;
|
||||
}
|
||||
|
||||
.micromodal-slide .tags {
|
||||
margin-right: 1rem;
|
||||
background-color: #f2f2f2;
|
||||
font-size: .8125rem;
|
||||
padding: 5px 8px;
|
||||
color: #111111;
|
||||
border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
-ms-border-radius: 3px;
|
||||
-o-border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":"1.0","provider_name":"Rijecki sportski savez","provider_url":"https:\/\/rss.hr","author_name":"RSS","author_url":"https:\/\/rss.hr\/author\/sasa\/","title":"SONKEI Respect in Sport, Respect in Life","type":"rich","width":600,"height":338,"html":"<blockquote class=\"wp-embedded-content\" data-secret=\"w59akSKiNc\"><a href=\"https:\/\/rss.hr\/sonkei-respect-in-sport-respect-in-life\/\">SONKEI Respect in Sport, Respect in Life<\/a><\/blockquote><iframe sandbox=\"allow-scripts\" security=\"restricted\" src=\"https:\/\/rss.hr\/sonkei-respect-in-sport-respect-in-life\/embed\/#?secret=w59akSKiNc\" width=\"600\" height=\"338\" title=\"“SONKEI Respect in Sport, Respect in Life” — Rijecki sportski savez\" data-secret=\"w59akSKiNc\" frameborder=\"0\" marginwidth=\"0\" marginheight=\"0\" scrolling=\"no\" class=\"wp-embedded-content\"><\/iframe><script>\n\/*! This file is auto-generated *\/\n!function(d,l){\"use strict\";l.querySelector&&d.addEventListener&&\"undefined\"!=typeof URL&&(d.wp=d.wp||{},d.wp.receiveEmbedMessage||(d.wp.receiveEmbedMessage=function(e){var t=e.data;if((t||t.secret||t.message||t.value)&&!\/[^a-zA-Z0-9]\/.test(t.secret)){for(var s,r,n,a=l.querySelectorAll('iframe[data-secret=\"'+t.secret+'\"]'),o=l.querySelectorAll('blockquote[data-secret=\"'+t.secret+'\"]'),c=new RegExp(\"^https?:$\",\"i\"),i=0;i<o.length;i++)o[i].style.display=\"none\";for(i=0;i<a.length;i++)s=a[i],e.source===s.contentWindow&&(s.removeAttribute(\"style\"),\"height\"===t.message?(1e3<(r=parseInt(t.value,10))?r=1e3:~~r<200&&(r=200),s.height=r):\"link\"===t.message&&(r=new URL(s.getAttribute(\"src\")),n=new URL(t.value),c.test(n.protocol))&&n.host===r.host&&l.activeElement===s&&(d.top.location.href=t.value))}},d.addEventListener(\"message\",d.wp.receiveEmbedMessage,!1),l.addEventListener(\"DOMContentLoaded\",function(){for(var e,t,s=l.querySelectorAll(\"iframe.wp-embedded-content\"),r=0;r<s.length;r++)(t=(e=s[r]).getAttribute(\"data-secret\"))||(t=Math.random().toString(36).substring(2,12),e.src+=\"#?secret=\"+t,e.setAttribute(\"data-secret\",t)),e.contentWindow.postMessage({message:\"ready\",secret:t},\"*\")},!1)))}(window,document);\n\/\/# sourceURL=https:\/\/rss.hr\/wp-includes\/js\/wp-embed.min.js\n<\/script>\n"}
|
||||
@@ -0,0 +1 @@
|
||||
.elementor-8321 .elementor-element.elementor-element-5a7d978d:not(.elementor-motion-effects-element-type-background), .elementor-8321 .elementor-element.elementor-element-5a7d978d > .elementor-motion-effects-container > .elementor-motion-effects-layer{background-color:#FFFFFF;background-image:url("https://rss.hr/wp-content/uploads/2022/01/flag-eu-europe-1463476.jpg");background-position:center center;background-size:cover;}.elementor-8321 .elementor-element.elementor-element-5a7d978d > .elementor-background-overlay{background-color:transparent;background-image:linear-gradient(180deg, #153243 0%, #192D31 100%);opacity:0.7;transition:background 0.3s, border-radius 0.3s, opacity 0.3s;}.elementor-8321 .elementor-element.elementor-element-5a7d978d > .elementor-container{min-height:300px;}.elementor-8321 .elementor-element.elementor-element-5a7d978d{transition:background 0.3s, border 0.3s, border-radius 0.3s, box-shadow 0.3s;padding:200px 0px 100px 0px;}.elementor-8321 .elementor-element.elementor-element-36e3182{text-align:center;}.elementor-8321 .elementor-element.elementor-element-36e3182 .elementor-heading-title{letter-spacing:2.5px;color:#FFFFFF;}.elementor-8321 .elementor-element.elementor-element-37512b3 > .elementor-widget-container{padding:15px 15px 15px 15px;}.elementor-8321 .elementor-element.elementor-element-37512b3{text-align:center;}.elementor-8321 .elementor-element.elementor-element-37512b3 img{width:100%;max-width:100%;height:192px;object-fit:contain;object-position:center center;}.elementor-8321 .elementor-element.elementor-element-f9184cf{text-align:start;}.elementor-8321 .elementor-element.elementor-element-6f65952{margin-top:0px;margin-bottom:0px;padding:0px 0px 0px 0px;}.elementor-8321 .elementor-element.elementor-element-968aaf0 > .elementor-widget-container{margin:0px 0px 0px 0px;padding:0px 0px 0px 0px;}.elementor-8321 .elementor-element.elementor-element-968aaf0{text-align:center;}.elementor-8321 .elementor-element.elementor-element-968aaf0 img{width:40%;max-width:40%;height:155px;object-fit:contain;object-position:center center;border-radius:0px 0px 0px 0px;}.elementor-8321 .elementor-element.elementor-element-23957eb{--spacer-size:10px;}body.elementor-page-8321:not(.elementor-motion-effects-element-type-background), body.elementor-page-8321 > .elementor-motion-effects-container > .elementor-motion-effects-layer{background-color:#ffffff;}@media(min-width:1025px){.elementor-8321 .elementor-element.elementor-element-5a7d978d:not(.elementor-motion-effects-element-type-background), .elementor-8321 .elementor-element.elementor-element-5a7d978d > .elementor-motion-effects-container > .elementor-motion-effects-layer{background-attachment:fixed;}}@media(max-width:1024px){.elementor-8321 .elementor-element.elementor-element-5a7d978d{padding:140px 80px 80px 80px;}.elementor-8321 .elementor-element.elementor-element-36e3182 .elementor-heading-title{font-size:40px;}.elementor-8321 .elementor-element.elementor-element-37512b3 img{width:60%;max-width:60%;}.elementor-8321 .elementor-element.elementor-element-968aaf0 img{width:50%;max-width:50%;}}@media(max-width:767px){.elementor-8321 .elementor-element.elementor-element-5a7d978d{padding:0px 0px 0px 0px;}.elementor-8321 .elementor-element.elementor-element-7b7c0473{width:100%;}.elementor-8321 .elementor-element.elementor-element-36e3182 .elementor-heading-title{font-size:30px;}.elementor-8321 .elementor-element.elementor-element-37512b3{text-align:center;}.elementor-8321 .elementor-element.elementor-element-37512b3 img{width:70%;max-width:70%;}.elementor-8321 .elementor-element.elementor-element-968aaf0{text-align:center;}.elementor-8321 .elementor-element.elementor-element-968aaf0 img{width:70%;max-width:70%;height:61px;}}
|
||||