Nova aplikacija "Namaz" (vaktija)

Vremena dnevnih namaza (sabah, podne, ikindija, akšam, jacija) sa api.vaktija.ba.
- idriz/apps/vaktija/store.py: izbor lokacije + bundlana lista (lokacije.json, 118
  lokacija; id=indeks). Sarajevo default. Bez openai/requests (uvozi ga admin).
- client.py: dohvat vakat[] po (lokacija, datum) sa keširanjem; API vraća lokalna
  vremena (sam vodi DST). vakat[1] je izlazak sunca (nije namaz), preskočen.
- manifest.py: alat namaz_vrijeme(koji): 'sljedeci' = prvi nadolazeći (ili sutra
  sabah ako su svi prošli); ili konkretan namaz (danas ako nadolazi, inače sutra).
  Bosanska gramatika za "za X sati/minuta" i rod (prošao/prošlo/prošla).
- admin.py + templates/vaktija.html: dropdown svih lokacija, Sarajevo default.
- Registrovano u load_apps() i ADMIN_APPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzKpYaEsuWDevuNnvSjcFW
This commit is contained in:
idriz
2026-09-11 18:48:59 +02:00
parent fd30ff2b8a
commit f17a16a34d
9 changed files with 318 additions and 2 deletions

View File

@@ -21,9 +21,10 @@ from flask import (
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from idriz.apps.obroci import admin as _obroci_admin # noqa: E402 from idriz.apps.obroci import admin as _obroci_admin # noqa: E402
from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402 from idriz.apps.superheroji import admin as _superheroji_admin # noqa: E402
from idriz.apps.vaktija import admin as _vaktija_admin # noqa: E402
# Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija). # Svaki app-admin modul ima BP (Blueprint) i NAV (stavka lijevog menija).
ADMIN_APPS = [_superheroji_admin, _obroci_admin] ADMIN_APPS = [_superheroji_admin, _obroci_admin, _vaktija_admin]
# --- Konfiguracija ----------------------------------------------------------- # --- Konfiguracija -----------------------------------------------------------

View File

@@ -12,4 +12,5 @@ def load_apps() -> list[AppSpec]:
from .obroci import manifest as obroci from .obroci import manifest as obroci
from .sazetak import manifest as sazetak from .sazetak import manifest as sazetak
from .superheroji import manifest as superheroji from .superheroji import manifest as superheroji
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC] from .vaktija import manifest as vaktija
return [sazetak.SPEC, bojanka.SPEC, superheroji.SPEC, obroci.SPEC, vaktija.SPEC]

View File

View File

@@ -0,0 +1,25 @@
"""Admin za 'Namaz': izbor lokacije iz dropdowna (Sarajevo default). Bez openai."""
from flask import Blueprint, flash, redirect, render_template, request, url_for
from . import store
BP = Blueprint("vaktija", __name__, template_folder="templates", url_prefix="/vaktija")
NAV = {"endpoint": "vaktija.index", "page": "vaktija", "title": "Namaz", "icon": "🕌"}
@BP.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
try:
saved = store.set_location_id(int(request.form.get("lokacija", "")))
flash(f"Lokacija postavljena: {store.location_name(saved)}.")
except (ValueError, TypeError):
flash("Neispravna lokacija.")
return redirect(url_for("vaktija.index"))
return render_template(
"vaktija.html",
lokacije=store.locations(),
odabrana=store.get_location_id(),
active_page="vaktija",
)

View File

@@ -0,0 +1,67 @@
"""Dohvat vremena namaza sa api.vaktija.ba, sa keširanjem po (lokacija, datum).
Koristi requests (uvozi ga samo asistent, ne admin). API vraća lokalna vremena
(sam vodi računa o ljetnom/zimskom vremenu). vakat[] ima 6 vrijednosti:
[sabah/zora, izlazak sunca, podne, ikindija, akšam, jacija].
"""
import datetime as dt
import json
import logging
import os
import tempfile
import threading
from pathlib import Path
import requests
log = logging.getLogger("idriz.vaktija")
CACHE_PATH = Path(os.environ.get(
"IDRIZ_VAKTIJA_CACHE_PATH", str(Path.home() / ".local/share/idriz/vaktija-cache.json")))
API = "https://api.vaktija.ba/vaktija/v1"
_lock = threading.Lock()
def _load_cache() -> dict:
try:
return json.loads(CACHE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_cache(cache: dict) -> None:
# drži zadnjih ~60 dana da fajl ne raste
if len(cache) > 60:
for k in sorted(cache.keys())[:-60]:
del cache[k]
with _lock:
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(CACHE_PATH.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(cache, f, ensure_ascii=False)
os.replace(tmp, CACHE_PATH)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def fetch_vakat(loc_id: int, date: dt.date) -> list[str] | None:
"""Vrati listu od 6 vremena ("H:MM") za datu lokaciju i datum, ili None ako ne uspije."""
key = f"{loc_id}-{date.isoformat()}"
cache = _load_cache()
if key in cache:
return cache[key]
url = f"{API}/{loc_id}/{date.year}/{date.month}/{date.day}"
try:
r = requests.get(url, timeout=10, headers={"User-Agent": "Idriz/0.1"})
r.raise_for_status()
vakat = r.json().get("vakat")
if isinstance(vakat, list) and len(vakat) == 6:
cache[key] = vakat
_save_cache(cache)
return vakat
log.warning("neočekivan odgovor vaktije za %s: %r", key, vakat)
except Exception: # noqa: BLE001
log.warning("vaktija API nije uspio za %s", key)
return None

View File

@@ -0,0 +1 @@
["Banovići","Banja Luka","Bihać","Bijeljina","Bileća","Bosanski Brod","Bosanska Dubica","Bosanska Gradiška","Bosansko Grahovo","Bosanska Krupa","Bosanski Novi","Bosanski Petrovac","Bosanski Šamac","Bratunac","Brčko","Breza","Bugojno","Busovača","Bužim","Cazin","Čajniče","Čapljina","Čelić","Čelinac","Čitluk","Derventa","Doboj","Donji Vakuf","Drvar","Foča","Fojnica","Gacko","Glamoč","Goražde","Gornji Vakuf","Gračanica","Gradačac","Grude","Hadžići","Han-Pijesak","Hlivno","Ilijaš","Jablanica","Jajce","Kakanj","Kalesija","Kalinovik","Kiseljak","Kladanj","Ključ","Konjic","Kotor-Varoš","Kreševo","Kupres","Laktaši","Lopare","Lukavac","Ljubinje","Ljubuški","Maglaj","Modriča","Mostar","Mrkonjić-Grad","Neum","Nevesinje","Novi Travnik","Odžak","Olovo","Orašje","Pale","Posušje","Prijedor","Prnjavor","Prozor","Rogatica","Rudo","Sanski Most","Sarajevo","Skender-Vakuf","Sokolac","Srbac","Srebrenica","Srebrenik","Stolac","Šekovići","Šipovo","Široki Brijeg","Teslić","Tešanj","Tomislav-Grad","Travnik","Trebinje","Trnovo","Tuzla","Ugljevik","Vareš","Velika Kladuša","Visoko","Višegrad","Vitez","Vlasenica","Zavidovići","Zenica","Zvornik","Žepa","Žepče","Živinice","Bijelo Polje","Gusinje","Nova Varoš","Novi Pazar","Plav","Pljevlja","Priboj","Prijepolje","Rožaje","Sjenica","Tutin"]

View File

@@ -0,0 +1,130 @@
"""Mini-app 'Namaz': vremena dnevnih namaza (vaktija) za izabranu lokaciju.
Odgovara na "kad je sljedeći namaz?" i "kad je ikindija/sabah/..." (danas ako
nadolazi, inače sutra). Podaci sa api.vaktija.ba (v. client.py); lokacija u adminu.
"""
import asyncio
import datetime as dt
import logging
from ..base import AppContext, AppSpec, AppTool
from . import client, store
log = logging.getLogger("idriz.vaktija")
# indeks u vakat[] po namazu (index 1 = izlazak sunca, nije namaz)
IDX = {"sabah": 0, "podne": 2, "ikindija": 3, "aksam": 4, "jacija": 5}
PRETTY = {"sabah": "sabah", "podne": "podne", "ikindija": "ikindija", "aksam": "akšam", "jacija": "jacija"}
REDOSLIJED = ["sabah", "podne", "ikindija", "aksam", "jacija"]
# rod za "već prošao/prošlo/prošla"
PROSAO = {"sabah": "prošao", "podne": "prošlo", "ikindija": "prošla", "aksam": "prošao", "jacija": "prošla"}
INSTRUCTIONS = """NAMAZ / VAKTIJA (alat: namaz_vrijeme): kazuješ vremena dnevnih namaza (sabah, podne, ikindija, akšam, jacija) za postavljenu lokaciju.
- Kad dijete pita "kad je sljedeći namaz?", pozovi namaz_vrijeme sa koji="sljedeci".
- Kad pita za određeni namaz ("kad je ikindija?", "kad je sabah?"), pozovi namaz_vrijeme sa tim imenom (sabah, podne, ikindija, aksam, jacija). Alat sam odlučuje odnosi li se na današnji (ako tek nadolazi) ili sutrašnji namaz (ako je današnji već prošao).
- Alat vraća gotovu rečenicu na bosanskom — izgovori je djetetu prirodno, bez izmišljanja vremena.
- Ako alat kaže da nema podataka, reci to iskreno (možda nema interneta)."""
SCHEMA = {
"type": "function",
"name": "namaz_vrijeme",
"description": "Vrijeme namaza za postavljenu lokaciju. koji='sljedeci' za sljedeći namaz, ili ime namaza (sabah/podne/ikindija/aksam/jacija) za konkretan (danas ako nadolazi, inače sutra).",
"parameters": {
"type": "object",
"properties": {
"koji": {"type": "string", "enum": ["sljedeci", "sabah", "podne", "ikindija", "aksam", "jacija"],
"description": "'sljedeci' ili ime namaza."},
},
"required": ["koji"],
},
}
def _sat_rijec(n: int) -> str:
if 11 <= n % 100 <= 14:
return "sati"
d = n % 10
return "sat" if d == 1 else ("sata" if 2 <= d <= 4 else "sati")
def _min_rijec(n: int) -> str:
if 11 <= n % 100 <= 14:
return "minuta"
d = n % 10
return "minutu" if d == 1 else ("minute" if 2 <= d <= 4 else "minuta")
def _za(delta_min: int) -> str:
if delta_min < 1:
return "za manje od minute"
h, m = divmod(delta_min, 60)
if h and m:
return f"za {h} {_sat_rijec(h)} i {m} {_min_rijec(m)}"
if h:
return f"za {h} {_sat_rijec(h)}"
return f"za {m} {_min_rijec(m)}"
def _dt(vakat: list[str], idx: int, date: dt.date) -> dt.datetime:
h, m = vakat[idx].split(":")
return dt.datetime(date.year, date.month, date.day, int(h), int(m))
def _hhmm(t: dt.datetime) -> str:
return t.strftime("%H:%M")
async def namaz_vrijeme(ctx: AppContext, args: dict) -> str:
koji = (args.get("koji") or "sljedeci").strip().lower()
loc_id = store.get_location_id()
loc = store.location_name(loc_id)
now = dt.datetime.now()
today = now.date()
vakat = await asyncio.to_thread(client.fetch_vakat, loc_id, today)
if not vakat:
return f"Trenutno ne mogu dobiti vremena namaza za {loc} (možda nema interneta). Reci to djetetu iskreno."
if koji == "sljedeci":
for name in REDOSLIJED:
t = _dt(vakat, IDX[name], today)
if t > now:
delta = int((t - now).total_seconds() // 60)
recenica = f"Sljedeći namaz je {PRETTY[name]}, u {_hhmm(t)}, {_za(delta)}."
return f'Reci djetetu prirodno na bosanskom: "{recenica}"'
# svi današnji prošli -> sutra sabah
tomorrow = today + dt.timedelta(days=1)
vsutra = await asyncio.to_thread(client.fetch_vakat, loc_id, tomorrow)
if not vsutra:
return "Danas su svi namazi prošli, a ne mogu dobiti sutrašnja vremena. Reci to djetetu."
t = _dt(vsutra, IDX["sabah"], tomorrow)
delta = int((t - now).total_seconds() // 60)
recenica = f"Svi današnji namazi su prošli. Sljedeći je sabah sutra, u {_hhmm(t)}, {_za(delta)}."
return f'Reci djetetu prirodno na bosanskom: "{recenica}"'
if koji not in IDX:
return "Nisam siguran koji namaz — pitaj dijete da ponovi (sabah, podne, ikindija, akšam ili jacija)."
t_today = _dt(vakat, IDX[koji], today)
if t_today > now:
delta = int((t_today - now).total_seconds() // 60)
recenica = f"{PRETTY[koji].capitalize()} je danas u {_hhmm(t_today)}, {_za(delta)}."
return f'Reci djetetu prirodno na bosanskom: "{recenica}"'
# danas prošao -> sutra
tomorrow = today + dt.timedelta(days=1)
vsutra = await asyncio.to_thread(client.fetch_vakat, loc_id, tomorrow)
if not vsutra:
return f"{PRETTY[koji].capitalize()} je danas već {PROSAO[koji]}, a ne mogu dobiti sutrašnje vrijeme. Reci to djetetu."
t = _dt(vsutra, IDX[koji], tomorrow)
delta = int((t - now).total_seconds() // 60)
recenica = f"{PRETTY[koji].capitalize()} je danas već {PROSAO[koji]}. Sutra je u {_hhmm(t)}, {_za(delta)}."
return f'Reci djetetu prirodno na bosanskom: "{recenica}"'
SPEC = AppSpec(
name="vaktija",
title="Namaz",
instructions=INSTRUCTIONS,
greeting="reći kad je koji namaz (vaktija)",
tools=[AppTool(SCHEMA, namaz_vrijeme, "inline")],
)

View File

@@ -0,0 +1,61 @@
"""Skladište za app 'Namaz': izbor lokacije + bundlana lista lokacija.
Bez openai/requests (uvozi ga i admin). Lista lokacija je bundlana (lokacije.json);
id lokacije = indeks u toj listi (kako radi api.vaktija.ba).
"""
import json
import os
import tempfile
import threading
from pathlib import Path
VAKTIJA_CONFIG_PATH = Path(os.environ.get(
"IDRIZ_VAKTIJA_CONFIG_PATH", str(Path.home() / ".config/idriz/vaktija.json")))
DEFAULT_ID = 77 # Sarajevo
_LOCATIONS_FILE = Path(__file__).with_name("lokacije.json")
_lock = threading.Lock()
try:
_LOCATIONS: list[str] = json.loads(_LOCATIONS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
_LOCATIONS = ["Sarajevo"]
def locations() -> list[dict]:
"""Sve lokacije kao [{'id': i, 'naziv': ...}], abecedno po nazivu."""
return sorted(({"id": i, "naziv": n} for i, n in enumerate(_LOCATIONS)),
key=lambda x: x["naziv"].lower())
def valid_id(loc_id: int) -> bool:
return isinstance(loc_id, int) and 0 <= loc_id < len(_LOCATIONS)
def location_name(loc_id: int) -> str:
return _LOCATIONS[loc_id] if valid_id(loc_id) else "Sarajevo"
def get_location_id() -> int:
try:
data = json.loads(VAKTIJA_CONFIG_PATH.read_text(encoding="utf-8"))
loc = int(data.get("lokacija_id", DEFAULT_ID))
except (OSError, ValueError, json.JSONDecodeError):
return DEFAULT_ID
return loc if valid_id(loc) else DEFAULT_ID
def set_location_id(loc_id: int) -> int:
if not valid_id(loc_id):
loc_id = DEFAULT_ID
with _lock:
VAKTIJA_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(VAKTIJA_CONFIG_PATH.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"lokacija_id": loc_id}, f, ensure_ascii=False, indent=2)
os.replace(tmp, VAKTIJA_CONFIG_PATH)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
return loc_id

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Namaz — Idriz Admin{% endblock %}
{% block extra_css %}
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 18px 20px; max-width: 460px; }
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel p.hint { color: var(--muted); font-size: 12.5px; margin: 0 0 14px; }
select { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid var(--line);
border-radius: 8px; background: #fff; color: var(--ink); }
select:focus { outline: none; border-color: var(--accent); }
button { margin-top: 14px; padding: 10px 20px; font-size: 14px; font-weight: 600; color: #fff;
background: var(--accent); border: none; border-radius: 8px; cursor: pointer; }
button:hover { filter: brightness(1.05); }
{% endblock %}
{% block content %}
<h1>Namaz (vaktija)</h1>
<p class="sub">Lokacija za koju Idriz kazuje vremena namaza.</p>
<div class="panel">
<h2>Lokacija</h2>
<p class="hint">Vremena se dohvataju sa api.vaktija.ba za odabrani grad.</p>
<form method="post">
<select name="lokacija">
{% for l in lokacije %}
<option value="{{ l.id }}" {{ 'selected' if l.id == odabrana else '' }}>{{ l.naziv }}</option>
{% endfor %}
</select>
<button type="submit">Sačuvaj</button>
</form>
</div>
{% endblock %}