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))
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.vaktija import admin as _vaktija_admin # noqa: E402
# 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 -----------------------------------------------------------

View File

@@ -12,4 +12,5 @@ def load_apps() -> list[AppSpec]:
from .obroci import manifest as obroci
from .sazetak import manifest as sazetak
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 %}