From 6890b9490b79fcfd4711f16efeaad5523855af1c Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Sun, 15 Jun 2025 18:04:11 +0000 Subject: [PATCH 1/9] =?UTF-8?q?=D0=9D=D0=B0=D1=87=D0=B0=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etc/.devcontainer/devcontainer.json | 36 +++++++++++++++++++ .../docker-compose.codespace.yml | 21 +++++++++++ 2 files changed, 57 insertions(+) create mode 100644 etc/.devcontainer/devcontainer.json create mode 100644 etc/.devcontainer/docker-compose.codespace.yml diff --git a/etc/.devcontainer/devcontainer.json b/etc/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9808d50 --- /dev/null +++ b/etc/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +{ + "name": "flask-example-codespace", + + // базовый compose + override для Codespace + "dockerComposeFile": [ + "../docker-compose.yml", + "docker-compose.codespace.yml" + ], + + // рабочий сервис – именно там лежит код + "service": "web-example", + "workspaceFolder": "/app", + + // порт, который отдаёт Nginx наружу + "forwardPorts": [8080], + "portsAttributes": { + "8080": { "label": "Flask (Nginx proxy)", "onAutoForward": "openPreview" } + }, + + // ставим Docker CLI (moby) внутрь dev‑контейнера + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": "true" + } + }, + + // полезные расширения + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-azuretools.vscode-docker" + ] + } + } +} diff --git a/etc/.devcontainer/docker-compose.codespace.yml b/etc/.devcontainer/docker-compose.codespace.yml new file mode 100644 index 0000000..4ca001e --- /dev/null +++ b/etc/.devcontainer/docker-compose.codespace.yml @@ -0,0 +1,21 @@ +services: + # Flask‑приложение + web-example: + build: + context: .. # корень репозитория + dockerfile: Dockerfile + command: > + gunicorn -k gevent --reload --workers 4 --bind :5000 app:app + expose: + - "5000" # остаётся внутренним, nginx проксирует + volumes: + - ..:/app:delegated # live‑маунт исходников + + # Nginx‑прокси + nginx: + image: nginx:latest + ports: + - "8080:8080" # важно повторно перечислить массив ports + volumes: + - ..:/app:ro + - ../etc/nginx.conf:/etc/nginx/conf.d/default.conf:ro From abaa00c8193efdd3bff7a34a0aa7adf8ada24bad Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Sun, 15 Jun 2025 20:49:33 +0000 Subject: [PATCH 2/9] =?UTF-8?q?=D0=9F=D0=A0=D0=9E=D0=92=D0=95=D0=A0=D0=9A?= =?UTF-8?q?=D0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 15 +++--- app.py | 101 +++++++++++++++++++++++++++++++++++----- auth.py | 52 +++++++++++++++++++++ models.py | 29 ++++++++++++ requirements.txt | 11 +++-- static/app.js | 42 +++++++++++++++++ static/style.css | 6 +++ static/theme.js | 13 ++++++ templates/base.html | 29 ++++++++++++ templates/hello.html | 5 -- templates/index.html | 60 ++++++++++++++++++++++++ templates/login.html | 11 +++++ templates/register.html | 0 13 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 auth.py create mode 100644 models.py create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 static/theme.js create mode 100644 templates/base.html delete mode 100644 templates/hello.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/register.html diff --git a/Dockerfile b/Dockerfile index c70e25c..8f051ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,11 @@ -FROM python:3.7 - -COPY requirements.txt / -RUN pip install --upgrade pip -RUN pip install --no-cache-dir -r /requirements.txt +FROM python:3.12-slim AS base +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +FROM base AS runtime +COPY . . +ENV PYTHONUNBUFFERED=1 EXPOSE 5000 - +HEALTHCHECK CMD curl -f http://localhost:5000/healthz || exit 1 +CMD ["gunicorn","-k","gevent","--bind","0.0.0.0:5000","app:app"] diff --git a/app.py b/app.py index 2795fbc..93b9759 100755 --- a/app.py +++ b/app.py @@ -1,15 +1,94 @@ -#!/usr/bin/env python3 +from __future__ import annotations +import pathlib, datetime as dt, time, uuid, logging +from flask import Flask, render_template, jsonify, abort, request +from flask_login import LoginManager, login_required, current_user +from werkzeug.security import generate_password_hash +from models import db, User, Student, Song, Attendance, StudentSong -from flask import Flask,request,render_template -from json import loads,dumps,load +PRICE = 130 +BASE = pathlib.Path(__file__).parent -app = Flask(__name__) -app.config['TEMPLATES_AUTO_RELOAD'] = True +app = Flask(__name__, static_folder="static", template_folder="templates") +app.config.update( + SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE/'app.db'}", + SECRET_KEY = "replace-me", + SQLALCHEMY_TRACK_MODIFICATIONS = False, +) +db.init_app(app) -@app.route('/') -def hello(): - if 'use_template' in request.args: - return render_template('hello.html') - else: - return 'Hello World!' +# ───── логін ───── +login = LoginManager(app) +login.login_view = "auth.login" +@login.user_loader +def load_user(uid): return db.session.get(User, uid) + +# ───── blueprints ───── +from auth import bp as auth_bp +app.register_blueprint(auth_bp) + +# ───── helpers ───── +def week_sum(stu_id: int) -> int: + return Attendance.query.filter_by(student_id=stu_id).count() * PRICE + +def total_sum() -> int: + return db.session.query(Attendance.id).count() * PRICE + +# ───── маршрути ───── +@app.get("/") +@login_required +def index(): + studs = (Student.query.all() if current_user.role == "teacher" + else Student.query.filter_by(parent_id=current_user.id).all()) + data = [{"id":s.id,"name":s.name,"week":week_sum(s.id)} for s in studs] + return render_template("index.html", + students=data, total=total_sum(), songs=Song.query.all()) + +@app.post("/api/attendance/") +@login_required +def add_lesson(stu_id): + Attendance(student_id=stu_id, date=dt.date.today()) + db.session.commit() + return jsonify({"week":week_sum(stu_id),"total":total_sum()}) + +@app.delete("/api/attendance/") +@login_required +def del_lesson(stu_id): + row = (Attendance.query.filter_by(student_id=stu_id) + .order_by(Attendance.id.desc()).first()) + if row: db.session.delete(row); db.session.commit() + return jsonify({"week":week_sum(stu_id),"total":total_sum()}) + +@app.post("/api/assign") +@login_required +def assign(): + sid = request.json.get("student_id"); tid = request.json.get("song_id") + if not (sid and tid): abort(400) + db.session.merge(StudentSong(student_id=sid, song_id=tid)) + db.session.commit(); return "", 201 + +@app.get("/healthz") +def health(): return "ok", 200 + +@app.get("/slow-analysis") +def slow(): time.sleep(2); return "done", 200 + +# ───── первичні дані ───── +with app.app_context(): + db.create_all() + if not User.query.first(): + admin = User(email="teacher@example.com", + password=generate_password_hash("secret"), + role="teacher") + db.session.add(admin); db.session.commit() + for n in ["Діана","Саша","Андріана","Маша","Ліза", + "Кіріл","Остап","Єва","Валерія","Аня"]: + db.session.add(Student(name=n,parent_id=admin.id)) + for t,d in [("Bluestone Alley",2),("Smells like teen spirit",1), + ("Horimia",3)]: + db.session.add(Song(title=t,difficulty=d)) + db.session.commit() + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + app.run(port=5000, debug=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..fd5955c --- /dev/null +++ b/auth.py @@ -0,0 +1,52 @@ +from flask import Blueprint, render_template, redirect, url_for, flash +from flask import request +from flask_login import login_user, logout_user, login_required +from werkzeug.security import generate_password_hash, check_password_hash +from models import db, User +from flask_wtf import FlaskForm +from wtforms import EmailField, PasswordField, SubmitField +from wtforms.validators import DataRequired, Email, Length + +bp = Blueprint("auth", __name__, url_prefix="/auth") + +# ───── WTForms ───── +class LoginForm(FlaskForm): + email = EmailField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Пароль", validators=[DataRequired(), Length(6)]) + submit = SubmitField("Увійти") + +class RegisterForm(LoginForm): + submit = SubmitField("Зареєструвати") + +# ───── маршрути ───── +@bp.route("/login", methods=["GET", "POST"]) +def login(): + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user and check_password_hash(user.password, form.password.data): + login_user(user); return redirect(url_for("index")) + flash("Невірний логін / пароль", "danger") + return render_template("login.html", form=form) + +@bp.route("/register", methods=["GET", "POST"]) +def register(): + form = RegisterForm() + if form.validate_on_submit(): + if User.query.filter_by(email=form.email.data).first(): + flash("Email вже існує", "warning") + else: + role = "teacher" if not User.query.first() else "parent" + user = User(email=form.email.data, + password=generate_password_hash(form.password.data), + role=role) + db.session.add(user); db.session.commit() + flash("Користувач створений", "success") + return redirect(url_for("auth.login")) + return render_template("register.html", form=form) + +@bp.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("auth.login")) diff --git a/models.py b/models.py new file mode 100644 index 0000000..8f85d3f --- /dev/null +++ b/models.py @@ -0,0 +1,29 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin + +db = SQLAlchemy() + +class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String, unique=True, nullable=False) + password = db.Column(db.String, nullable=False) + role = db.Column(db.String, default="parent") # teacher | parent + +class Student(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String, nullable=False) + parent_id = db.Column(db.Integer, db.ForeignKey("user.id")) + +class Song(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String, nullable=False) + difficulty = db.Column(db.Integer, default=1) # 1‑4 ★ + +class Attendance(db.Model): + id = db.Column(db.Integer, primary_key=True) + student_id = db.Column(db.Integer, db.ForeignKey("student.id")) + date = db.Column(db.Date, nullable=False) + +class StudentSong(db.Model): + student_id = db.Column(db.Integer, db.ForeignKey("student.id"), primary_key=True) + song_id = db.Column(db.Integer, db.ForeignKey("song.id"), primary_key=True) diff --git a/requirements.txt b/requirements.txt index fe9fe10..fcf06c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -flask>=0.12.2 -gunicorn>=19.7.0 -gevent>=1.4 - +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 # формы + CSRF +email-validator==2.1.1 # требование WTForms +gunicorn==22.0.0 +gevent==24.2.1 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..3a75f86 --- /dev/null +++ b/static/app.js @@ -0,0 +1,42 @@ +/* global fetch */ + +document.addEventListener("DOMContentLoaded", () => { + + /* ───── обработчики + / – ───── */ + document.querySelectorAll(".add-btn").forEach(btn => { + btn.addEventListener("click", () => handleLesson(btn.dataset.id, "POST")); + }); + document.querySelectorAll(".del-btn").forEach(btn => { + btn.addEventListener("click", () => handleLesson(btn.dataset.id, "DELETE")); + }); + + async function handleLesson(id, method) { + const r = await fetch(`/api/attendance/${id}`, { method }); + if (!r.ok) return; + const j = await r.json(); + updateRow(id, j); + } + + function updateRow(id, j) { + const row = document.getElementById(`row-${id}`); + row.querySelector(".lessons").innerText = j.week / 130; + row.querySelector(".sum").innerText = j.week; + document.getElementById("total").innerText = `Усього: ${j.total} ₴`; + } + + /* ───── assign song ───── */ + document.querySelectorAll(".song-select").forEach(sel => { + sel.addEventListener("change", async () => { + const song = sel.dataset.songId; + const stud = sel.value; + if (!stud) return; + await fetch("/api/assign", { + method : "POST", + headers: { "Content-Type": "application/json" }, + body : JSON.stringify({ student_id: stud, song_id: song }) + }); + sel.selectedIndex = 0; // сбросить выпадайку + }); + }); + +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..c464401 --- /dev/null +++ b/static/style.css @@ -0,0 +1,6 @@ +body{ + background:linear-gradient(135deg,#e9f2ff 0%,#ffffff 100%); +} +[data-bs-theme="dark"] body{ + background:linear-gradient(135deg,#2b2e36 0%,#202227 100%); +} diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 0000000..9274409 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,13 @@ +const btn=document.getElementById('themeToggle'); +if(btn){ + const cur=localStorage.getItem('theme')||'light'; + document.documentElement.setAttribute('data-bs-theme',cur); + btn.innerText=cur==='light'?'Dark':'Light'; + btn.onclick=()=>{ + const now=document.documentElement.getAttribute('data-bs-theme'); + const next=now==='light'?'dark':'light'; + document.documentElement.setAttribute('data-bs-theme',next); + localStorage.setItem('theme',next); + btn.innerText=next==='light'?'Dark':'Light'; + }; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ad12213 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,29 @@ + + + + +{{ title|default('Music School') }} + + + + + + +
+ {% with m = get_flashed_messages(with_categories=true) %} + {% for cat,msg in m %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ diff --git a/templates/hello.html b/templates/hello.html deleted file mode 100644 index b9b2c43..0000000 --- a/templates/hello.html +++ /dev/null @@ -1,5 +0,0 @@ - - -

Hello Template!

- - diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e486246 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block content %} + +

Відвідуваність та оплата

+ + + + + + + + + + + + + {% for s in students %} + + + + + + + {% endfor %} + + + + + + + +
УченьУроки₴ / тиждень
{{ s.name }}{{ '%d' % (s.week / 130) }}{{ s.week }} + + +
+ Усього: {{ total }} ₴ +
+ +

Пісні

+
    + {% for t in songs %} +
  • + {{ t.title }} +
    + {{ '★' * t.difficulty }} + +
    +
  • + {% endfor %} +
+ + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c49eff6 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +

Логін

+
+ {{ form.csrf_token }} +
{{ form.email.label }} {{ form.email(class="form-control") }}
+
{{ form.password.label }} {{ form.password(class="form-control") }}
+ {{ form.submit(class="btn btn-primary") }} + Реєстрація +
+{% endblock %} diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..e69de29 From f8385f66371b9e89aa18770d9e78cffbf7651b47 Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Sun, 15 Jun 2025 22:36:18 +0000 Subject: [PATCH 3/9] =?UTF-8?q?=D0=9E=D0=A8=D0=98=D0=91=D0=9A=D0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 71 +++++++++++++++++++++++++++------------------ data/app.db | Bin 0 -> 32768 bytes docker-compose.yml | 20 ++++++++----- 3 files changed, 55 insertions(+), 36 deletions(-) create mode 100644 data/app.db diff --git a/app.py b/app.py index 93b9759..5c48ca7 100755 --- a/app.py +++ b/app.py @@ -1,30 +1,33 @@ from __future__ import annotations -import pathlib, datetime as dt, time, uuid, logging +import pathlib, os, datetime as dt, time, uuid, logging from flask import Flask, render_template, jsonify, abort, request from flask_login import LoginManager, login_required, current_user from werkzeug.security import generate_password_hash from models import db, User, Student, Song, Attendance, StudentSong PRICE = 130 -BASE = pathlib.Path(__file__).parent +BASE = pathlib.Path(__file__).parent # /app (read‑only внутри контейнера) +DATA_DIR = pathlib.Path("/data") # отдельный том под SQLite +DATA_DIR.mkdir(parents=True, exist_ok=True) app = Flask(__name__, static_folder="static", template_folder="templates") app.config.update( - SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE/'app.db'}", - SECRET_KEY = "replace-me", - SQLALCHEMY_TRACK_MODIFICATIONS = False, + SQLALCHEMY_DATABASE_URI=f"sqlite:///{DATA_DIR/'app.db'}", + SECRET_KEY="replace-me", + SQLALCHEMY_TRACK_MODIFICATIONS=False, ) db.init_app(app) -# ───── логін ───── +# ───── Flask‑Login ───── login = LoginManager(app) login.login_view = "auth.login" @login.user_loader -def load_user(uid): return db.session.get(User, uid) +def load_user(uid): # noqa: D401 + return db.session.get(User, uid) -# ───── blueprints ───── -from auth import bp as auth_bp +# ───── Blueprint auth ───── +from auth import bp as auth_bp # noqa: E402 (импорт после инициализации app) app.register_blueprint(auth_bp) # ───── helpers ───── @@ -34,13 +37,13 @@ def week_sum(stu_id: int) -> int: def total_sum() -> int: return db.session.query(Attendance.id).count() * PRICE -# ───── маршрути ───── +# ───── Routes ───── @app.get("/") @login_required def index(): studs = (Student.query.all() if current_user.role == "teacher" else Student.query.filter_by(parent_id=current_user.id).all()) - data = [{"id":s.id,"name":s.name,"week":week_sum(s.id)} for s in studs] + data = [{"id": s.id, "name": s.name, "week": week_sum(s.id)} for s in studs] return render_template("index.html", students=data, total=total_sum(), songs=Song.query.all()) @@ -49,46 +52,58 @@ def index(): def add_lesson(stu_id): Attendance(student_id=stu_id, date=dt.date.today()) db.session.commit() - return jsonify({"week":week_sum(stu_id),"total":total_sum()}) + return jsonify({"week": week_sum(stu_id), "total": total_sum()}) @app.delete("/api/attendance/") @login_required def del_lesson(stu_id): row = (Attendance.query.filter_by(student_id=stu_id) .order_by(Attendance.id.desc()).first()) - if row: db.session.delete(row); db.session.commit() - return jsonify({"week":week_sum(stu_id),"total":total_sum()}) + if row: + db.session.delete(row) + db.session.commit() + return jsonify({"week": week_sum(stu_id), "total": total_sum()}) @app.post("/api/assign") @login_required def assign(): - sid = request.json.get("student_id"); tid = request.json.get("song_id") - if not (sid and tid): abort(400) + payload = request.get_json(force=True) + sid, tid = payload.get("student_id"), payload.get("song_id") + if not (sid and tid): + abort(400) db.session.merge(StudentSong(student_id=sid, song_id=tid)) - db.session.commit(); return "", 201 + db.session.commit() + return "", 201 @app.get("/healthz") -def health(): return "ok", 200 +def health(): + return "ok", 200 @app.get("/slow-analysis") -def slow(): time.sleep(2); return "done", 200 +def slow(): + time.sleep(2) + return "done", 200 -# ───── первичні дані ───── +# ───── Seed (первый запуск) ───── with app.app_context(): db.create_all() if not User.query.first(): admin = User(email="teacher@example.com", password=generate_password_hash("secret"), role="teacher") - db.session.add(admin); db.session.commit() - for n in ["Діана","Саша","Андріана","Маша","Ліза", - "Кіріл","Остап","Єва","Валерія","Аня"]: - db.session.add(Student(name=n,parent_id=admin.id)) - for t,d in [("Bluestone Alley",2),("Smells like teen spirit",1), - ("Horimia",3)]: - db.session.add(Song(title=t,difficulty=d)) + db.session.add(admin) + db.session.commit() + + for n in ["Діана", "Саша", "Андріана", "Маша", "Ліза", + "Кіріл", "Остап", "Єва", "Валерія", "Аня"]: + db.session.add(Student(name=n, parent_id=admin.id)) + + for title, diff in [("Bluestone Alley", 2), + ("Smells like teen spirit", 1), + ("Horimia", 3)]: + db.session.add(Song(title=title, difficulty=diff)) db.session.commit() if __name__ == "__main__": logging.basicConfig(level=logging.INFO) - app.run(port=5000, debug=True) + app.run(port=5000, debug=True, host="0.0.0.0") diff --git a/data/app.db b/data/app.db new file mode 100644 index 0000000000000000000000000000000000000000..e8e89992d8f8472dd315cf6d0ca06ad7a16da133 GIT binary patch literal 32768 zcmeI)&2QUe90%~njhi%CTO+g*p$h4#w2DTQDUS2vwwI-?x0Q7nO>?R~(2_Q~TeILci^J@S6c^*}I z95wrUyN<`GU2jx%k1FIPA_(LPrG$_eKW6yRPt!aj_6z(|7|Kt|#>iz;n3H}Zu?x?L zM5Q0+AI&|VOI~<3`+U+6)I$IQ5P$##AOHafKmY>&LZD5evE=fy@WGa+8!l^lCl;%D zm1ezmG@OrZm9^r5Mh}XcyBZyCp(}}azwB6ar*xoQ)5^4T>wuOHcXwAQZ{{bfOlW(% zymPZyzD;jvx9Q5@Jgc-fC>5OG>aDW2bFCC~8tRjxW$mg~)=FF2em^9-;#e;ZIlKO7 ze_j`MPNWL4X!6FgK%APz?lteae5+H@PrQ0quJmIHm}>>X-!CA>r`k$Qb9n%r2JwBUm{D{}B2 z;eSuUz5N`|2+{`OFC+*+00Izz00bZa0SG_<0uX=z1YQ(qZ3Q}5b{dX%Q4q-+!lrw|{}ZTQ zW3=eH>~t#F|0g7iNWV%yN#9G~Nge46>7jIAI_8x~5P$##AOHafKmY;|fB*y_009X6 zV}V3MB(KK(hyIi9gO}qXNyYt-{Eq+F|EBwX_ml2}#7x+J-~Y<*%*RBs6!$;xwz_TJ z=jnVjEI;D?cwhhV?6gQ;i~FDQ{I`B*Rt!s@`JL{Ee&-^881QCz9sldFcgKIi`|yVX ze8iV$edhU70Dk_D`S`Fk*#Gk%{X>EP1Rwwb2tWV=5P$##AOHafK;Rq;VE=!PCl|K{ z0SG_<0uX=z1Rwwb2tWV=5a Date: Sun, 15 Jun 2025 22:53:31 +0000 Subject: [PATCH 4/9] =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9?= =?UTF-8?q?=D1=8F,=20=D0=BD=D0=BE=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 72 +++++++++++++++++----------------------- auth.py | 38 +++++++++++++-------- data/app.db | Bin 32768 -> 32768 bytes docker-compose.yml | 45 +++++++++++++------------ etc/nginx.conf | 21 ++++++------ models.py | 4 +-- requirements.txt | 4 +-- static/app.js | 9 +++-- templates/index.html | 22 ++++++------ templates/login.html | 41 ++++++++++++++++++----- templates/register.html | 55 ++++++++++++++++++++++++++++++ 11 files changed, 195 insertions(+), 116 deletions(-) diff --git a/app.py b/app.py index 5c48ca7..f9d4f82 100755 --- a/app.py +++ b/app.py @@ -1,14 +1,12 @@ from __future__ import annotations -import pathlib, os, datetime as dt, time, uuid, logging +import pathlib, datetime as dt, time, logging from flask import Flask, render_template, jsonify, abort, request from flask_login import LoginManager, login_required, current_user from werkzeug.security import generate_password_hash from models import db, User, Student, Song, Attendance, StudentSong PRICE = 130 -BASE = pathlib.Path(__file__).parent # /app (read‑only внутри контейнера) -DATA_DIR = pathlib.Path("/data") # отдельный том под SQLite -DATA_DIR.mkdir(parents=True, exist_ok=True) +DATA_DIR = pathlib.Path("/data"); DATA_DIR.mkdir(parents=True, exist_ok=True) app = Flask(__name__, static_folder="static", template_folder="templates") app.config.update( @@ -18,31 +16,30 @@ ) db.init_app(app) -# ───── Flask‑Login ───── -login = LoginManager(app) -login.login_view = "auth.login" - +# ───── login ───── +login = LoginManager(app); login.login_view = "auth.login" @login.user_loader -def load_user(uid): # noqa: D401 - return db.session.get(User, uid) +def load_user(uid): return db.session.get(User, uid) -# ───── Blueprint auth ───── -from auth import bp as auth_bp # noqa: E402 (импорт после инициализации app) +# ───── blueprint auth ───── +from auth import bp as auth_bp # noqa: E402 app.register_blueprint(auth_bp) # ───── helpers ───── def week_sum(stu_id: int) -> int: return Attendance.query.filter_by(student_id=stu_id).count() * PRICE - def total_sum() -> int: return db.session.query(Attendance.id).count() * PRICE +def admin_required(): + if current_user.role != "teacher": + abort(403, "Тільки адміністратор може виконати дію") -# ───── Routes ───── +# ───── routes ───── @app.get("/") @login_required def index(): - studs = (Student.query.all() if current_user.role == "teacher" - else Student.query.filter_by(parent_id=current_user.id).all()) + studs = Student.query.all() if current_user.role == "teacher" \ + else Student.query.filter_by(parent_id=current_user.id).all() data = [{"id": s.id, "name": s.name, "week": week_sum(s.id)} for s in studs] return render_template("index.html", students=data, total=total_sum(), songs=Song.query.all()) @@ -50,6 +47,7 @@ def index(): @app.post("/api/attendance/") @login_required def add_lesson(stu_id): + admin_required() Attendance(student_id=stu_id, date=dt.date.today()) db.session.commit() return jsonify({"week": week_sum(stu_id), "total": total_sum()}) @@ -57,53 +55,43 @@ def add_lesson(stu_id): @app.delete("/api/attendance/") @login_required def del_lesson(stu_id): - row = (Attendance.query.filter_by(student_id=stu_id) - .order_by(Attendance.id.desc()).first()) - if row: - db.session.delete(row) - db.session.commit() + admin_required() + row = Attendance.query.filter_by(student_id=stu_id)\ + .order_by(Attendance.id.desc()).first() + if row: db.session.delete(row); db.session.commit() return jsonify({"week": week_sum(stu_id), "total": total_sum()}) @app.post("/api/assign") @login_required def assign(): + admin_required() payload = request.get_json(force=True) sid, tid = payload.get("student_id"), payload.get("song_id") - if not (sid and tid): - abort(400) - db.session.merge(StudentSong(student_id=sid, song_id=tid)) - db.session.commit() + if not (sid and tid): abort(400) + db.session.merge(StudentSong(student_id=sid, song_id=tid)); db.session.commit() return "", 201 @app.get("/healthz") -def health(): - return "ok", 200 +def health(): return "ok", 200 @app.get("/slow-analysis") -def slow(): - time.sleep(2) - return "done", 200 +def slow(): time.sleep(2); return "done", 200 -# ───── Seed (первый запуск) ───── +# ───── seed ───── with app.app_context(): db.create_all() if not User.query.first(): admin = User(email="teacher@example.com", password=generate_password_hash("secret"), role="teacher") - db.session.add(admin) - db.session.commit() - - for n in ["Діана", "Саша", "Андріана", "Маша", "Ліза", - "Кіріл", "Остап", "Єва", "Валерія", "Аня"]: + db.session.add(admin); db.session.commit() + for n in ["Діана","Саша","Андріана","Маша","Ліза", + "Кіріл","Остап","Єва","Валерія","Аня"]: db.session.add(Student(name=n, parent_id=admin.id)) - - for title, diff in [("Bluestone Alley", 2), - ("Smells like teen spirit", 1), - ("Horimia", 3)]: - db.session.add(Song(title=title, difficulty=diff)) + for t,d in [("Bluestone Alley",2),("Smells like teen spirit",1),("Horimia",3)]: + db.session.add(Song(title=t, difficulty=d)) db.session.commit() if __name__ == "__main__": logging.basicConfig(level=logging.INFO) - app.run(port=5000, debug=True, host="0.0.0.0") + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/auth.py b/auth.py index fd5955c..89ae4d0 100644 --- a/auth.py +++ b/auth.py @@ -1,13 +1,13 @@ from flask import Blueprint, render_template, redirect, url_for, flash -from flask import request from flask_login import login_user, logout_user, login_required from werkzeug.security import generate_password_hash, check_password_hash from models import db, User from flask_wtf import FlaskForm -from wtforms import EmailField, PasswordField, SubmitField -from wtforms.validators import DataRequired, Email, Length +from wtforms import EmailField, PasswordField, SubmitField, RadioField +from wtforms.validators import DataRequired, Email, Length, ValidationError bp = Blueprint("auth", __name__, url_prefix="/auth") +ADMIN_INVITE_CODE = "admin123" # измените при необходимости # ───── WTForms ───── class LoginForm(FlaskForm): @@ -15,17 +15,28 @@ class LoginForm(FlaskForm): password = PasswordField("Пароль", validators=[DataRequired(), Length(6)]) submit = SubmitField("Увійти") -class RegisterForm(LoginForm): - submit = SubmitField("Зареєструвати") +class RegisterForm(FlaskForm): + email = EmailField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Пароль", validators=[DataRequired(), Length(6)]) + role = RadioField("Роль", choices=[("parent", "Батьки / Учень"), + ("teacher", "Адмін")], + default="parent", validators=[DataRequired()]) + admin_code = PasswordField("Код для адміна") + submit = SubmitField("Зареєструватись") -# ───── маршрути ───── + def validate_admin_code(form, field): + if form.role.data == "teacher" and field.data != ADMIN_INVITE_CODE: + raise ValidationError("Невірний admin‑код") + +# ───── маршруты ───── @bp.route("/login", methods=["GET", "POST"]) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user and check_password_hash(user.password, form.password.data): - login_user(user); return redirect(url_for("index")) + login_user(user) + return redirect(url_for("index")) flash("Невірний логін / пароль", "danger") return render_template("login.html", form=form) @@ -34,14 +45,13 @@ def register(): form = RegisterForm() if form.validate_on_submit(): if User.query.filter_by(email=form.email.data).first(): - flash("Email вже існує", "warning") + flash("Email вже зареєстровано", "warning") else: - role = "teacher" if not User.query.first() else "parent" - user = User(email=form.email.data, - password=generate_password_hash(form.password.data), - role=role) - db.session.add(user); db.session.commit() - flash("Користувач створений", "success") + new_user = User(email=form.email.data, + password=generate_password_hash(form.password.data), + role=form.role.data) + db.session.add(new_user); db.session.commit() + flash("Успішно! Увійдіть під своїм логіном.", "success") return redirect(url_for("auth.login")) return render_template("register.html", form=form) diff --git a/data/app.db b/data/app.db index e8e89992d8f8472dd315cf6d0ca06ad7a16da133..04ebcbe6af8bcc3a33fd01cf97f7c57ebcdbb179 100644 GIT binary patch delta 290 zcmZ|KJxT*X7=Yp35oD{3sVoxFBDK!{Og03B6c!Ow4A?qk-;ahHqybdl$;M zxGBo#sU_m@YG$u*_kA&Y@a5vZ$~n`@&=1+hWwMQZWau<&%gPl HzIXrtZ4VC= diff --git a/docker-compose.yml b/docker-compose.yml index 89861ce..db2ea5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,29 @@ -version: "3.5" - services: + nginx: + image: nginx:latest + ports: + - "8080:8080" + restart: always + volumes: + - ./static:/www/static:ro + - ./etc/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - web-example + web-example: build: context: . - # … остальные поля (ports, depends_on) оставьте без изменений … - + dockerfile: Dockerfile + expose: + - "5000" + command: + - /usr/local/bin/gunicorn + - -k + - gevent + - --bind + - 0.0.0.0:5000 + - app:app volumes: - # ➊ исходный код монтируем «только‑чтение» (как было) - - ./:/app:ro - - # ➋ новая папка для данных SQLite (read‑write) - - ./data:/data # ← папка data создастся рядом с compose‑файлом - - - nginx: - restart: always - image: nginx:latest - ports: - - "8080:8080" - volumes: - - ./static:/www/static:ro - - ./etc/nginx.conf:/etc/nginx/conf.d/default.conf - - + - ./:/app:ro # код read‑only + - ./data:/data # БД read‑write + restart: unless-stopped diff --git a/etc/nginx.conf b/etc/nginx.conf index 6381518..2c820f4 100644 --- a/etc/nginx.conf +++ b/etc/nginx.conf @@ -1,18 +1,17 @@ server { - listen 8080 default_server; - server_name _; - charset utf-8; + listen 8080; - location /static { - alias /www/static; + location /static/ { + alias /www/static/; + access_log off; + expires 7d; } location / { - proxy_pass http://web-example:5000; - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_http_version 1.1; + proxy_pass http://web-example:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } - diff --git a/models.py b/models.py index 8f85d3f..662f111 100644 --- a/models.py +++ b/models.py @@ -7,7 +7,7 @@ class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String, nullable=False) - role = db.Column(db.String, default="parent") # teacher | parent + role = db.Column(db.String, default="parent") # teacher | parent class Student(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -17,7 +17,7 @@ class Student(db.Model): class Song(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String, nullable=False) - difficulty = db.Column(db.Integer, default=1) # 1‑4 ★ + difficulty = db.Column(db.Integer, default=1) # 1‑4 ★ class Attendance(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/requirements.txt b/requirements.txt index fcf06c1..03e238c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ Flask==3.0.3 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 -Flask-WTF==1.2.1 # формы + CSRF -email-validator==2.1.1 # требование WTForms +Flask-WTF==1.2.1 +email-validator==2.1.1 gunicorn==22.0.0 gevent==24.2.1 diff --git a/static/app.js b/static/app.js index 3a75f86..8de62f4 100644 --- a/static/app.js +++ b/static/app.js @@ -2,7 +2,7 @@ document.addEventListener("DOMContentLoaded", () => { - /* ───── обработчики + / – ───── */ + /* lesson +/- кнопки (только у админа) */ document.querySelectorAll(".add-btn").forEach(btn => { btn.addEventListener("click", () => handleLesson(btn.dataset.id, "POST")); }); @@ -16,15 +16,14 @@ document.addEventListener("DOMContentLoaded", () => { const j = await r.json(); updateRow(id, j); } - function updateRow(id, j) { const row = document.getElementById(`row-${id}`); row.querySelector(".lessons").innerText = j.week / 130; - row.querySelector(".sum").innerText = j.week; + row.querySelector(".sum").innerText = j.week; document.getElementById("total").innerText = `Усього: ${j.total} ₴`; } - /* ───── assign song ───── */ + /* assign song (только у админа) */ document.querySelectorAll(".song-select").forEach(sel => { sel.addEventListener("change", async () => { const song = sel.dataset.songId; @@ -35,7 +34,7 @@ document.addEventListener("DOMContentLoaded", () => { headers: { "Content-Type": "application/json" }, body : JSON.stringify({ student_id: stud, song_id: song }) }); - sel.selectedIndex = 0; // сбросить выпадайку + sel.selectedIndex = 0; }); }); diff --git a/templates/index.html b/templates/index.html index e486246..a1b7f9a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,13 +3,11 @@

Відвідуваність та оплата

- +
- - - - + + {% if current_user.role == 'teacher' %}{% endif %} @@ -19,18 +17,21 @@

Відвідуваність та оплата

+ {% if current_user.role == 'teacher' %} + {% endif %} {% endfor %} - @@ -43,18 +44,17 @@

Пісні

{{ t.title }}
{{ '★' * t.difficulty }} + {% if current_user.role == 'teacher' %} + {% endif %}
{% endfor %} - {% endblock %} diff --git a/templates/login.html b/templates/login.html index c49eff6..432e48f 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,11 +1,36 @@ {% extends "base.html" %} {% block content %} -

Логін

- - {{ form.csrf_token }} -
{{ form.email.label }} {{ form.email(class="form-control") }}
-
{{ form.password.label }} {{ form.password(class="form-control") }}
- {{ form.submit(class="btn btn-primary") }} - Реєстрація - +
+
+ +

Вхід до кабінету

+ +
+ {{ form.csrf_token }} + +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control", placeholder="teacher@example.com") }} + {% for err in form.email.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control", placeholder="••••••") }} + {% for err in form.password.errors %} +
{{ err }}
+ {% endfor %} +
+ + {{ form.submit(class="btn btn-primary w-100") }} + +

+ Не маєте акаунту? Реєстрація +

+ + +
+
{% endblock %} diff --git a/templates/register.html b/templates/register.html index e69de29..e50fd7e 100644 --- a/templates/register.html +++ b/templates/register.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +

Реєстрація

+ +
+ {{ form.csrf_token }} + +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control", placeholder="example@mail.com") }} + {% for err in form.email.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control", placeholder="мінімум 6 символів") }} + {% for err in form.password.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.role.label(class="form-label d-block") }} + {% for sub in form.role %} +
+ {{ sub(class="form-check-input") }} + {{ sub.label(class="form-check-label") }} +
+ {% endfor %} +
+ +
+ {{ form.admin_code.label(class="form-label") }} + {{ form.admin_code(class="form-control", placeholder="тільки для адміна") }} + {% for err in form.admin_code.errors %} +
{{ err }}
+ {% endfor %} +
Поле не потрібне, якщо реєструєтесь як батьки.
+
+ + {{ form.submit(class="btn btn-success w-100") }} + +

+ Вже маєте акаунт? Увійти +

+ + +
+
+{% endblock %} From 0752787b386d5f2b4795c4c4b8e7cd0a35486567 Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Sun, 15 Jun 2025 23:18:15 +0000 Subject: [PATCH 5/9] =?UTF-8?q?=D0=B2=D0=B6=D0=B5=20=D0=BC=D0=B0=D0=B9?= =?UTF-8?q?=D0=B6=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 112 ++++++++++++++++++++++++++++++------------- data/app.db | Bin 32768 -> 36864 bytes models.py | 10 ++-- static/app.js | 61 ++++++++++++++++------- static/style.css | 4 +- templates/index.html | 93 +++++++++++++++++++++++------------ 6 files changed, 195 insertions(+), 85 deletions(-) diff --git a/app.py b/app.py index f9d4f82..ae6399c 100755 --- a/app.py +++ b/app.py @@ -16,68 +16,112 @@ ) db.init_app(app) -# ───── login ───── +# ───── Login ───── login = LoginManager(app); login.login_view = "auth.login" @login.user_loader def load_user(uid): return db.session.get(User, uid) -# ───── blueprint auth ───── -from auth import bp as auth_bp # noqa: E402 +# ───── Blueprint auth ───── +from auth import bp as auth_bp # noqa: E402 app.register_blueprint(auth_bp) # ───── helpers ───── -def week_sum(stu_id: int) -> int: - return Attendance.query.filter_by(student_id=stu_id).count() * PRICE -def total_sum() -> int: - return db.session.query(Attendance.id).count() * PRICE +def week_range(today: dt.date) -> tuple[dt.date, dt.date]: + start = today - dt.timedelta(days=today.weekday()) # Monday + end = start + dt.timedelta(days=6) # Sunday + return start, end + +def week_dates(today: dt.date) -> list[dt.date]: + s, _ = week_range(today); return [s + dt.timedelta(d) for d in range(7)] + +def week_sum(stu_id: int, start: dt.date, end: dt.date) -> int: + cnt = Attendance.query.filter_by(student_id=stu_id)\ + .filter(Attendance.date.between(start, end)).count() + return cnt * PRICE + def admin_required(): if current_user.role != "teacher": abort(403, "Тільки адміністратор може виконати дію") -# ───── routes ───── +# ───── Routes ───── @app.get("/") @login_required def index(): - studs = Student.query.all() if current_user.role == "teacher" \ - else Student.query.filter_by(parent_id=current_user.id).all() - data = [{"id": s.id, "name": s.name, "week": week_sum(s.id)} for s in studs] + today = dt.date.today() + week_start, week_end = week_range(today) + days = week_dates(today) + + studs = (Student.query.all() if current_user.role == "teacher" + else Student.query.filter_by(parent_id=current_user.id).all()) + + # Attendance dict: {student_id: set("YYYY-MM-DD", ...)} + attend = {s.id: set() for s in studs} + for a in Attendance.query.filter(Attendance.date.between(week_start, week_end)).all(): + attend.setdefault(a.student_id, set()).add(a.date.isoformat()) + + # список студентов + недельная сумма + data = [{"id": s.id, "name": s.name, + "week": week_sum(s.id, week_start, week_end)} for s in studs] + return render_template("index.html", - students=data, total=total_sum(), songs=Song.query.all()) + students=data, days=days, attend=attend, price=PRICE, + total=sum(d["week"] for d in data), songs=Song.query.all()) -@app.post("/api/attendance/") +# ───── API: attendance toggle ───── +@app.post("/api/attendance/toggle") @login_required -def add_lesson(stu_id): +def toggle_attendance(): admin_required() - Attendance(student_id=stu_id, date=dt.date.today()) + sid = request.json.get("student_id") + d = dt.date.fromisoformat(request.json.get("date")) + row = Attendance.query.filter_by(student_id=sid, date=d).first() + if row: + db.session.delete(row) + else: + db.session.add(Attendance(student_id=sid, date=d)) db.session.commit() - return jsonify({"week": week_sum(stu_id), "total": total_sum()}) - -@app.delete("/api/attendance/") -@login_required -def del_lesson(stu_id): - admin_required() - row = Attendance.query.filter_by(student_id=stu_id)\ - .order_by(Attendance.id.desc()).first() - if row: db.session.delete(row); db.session.commit() - return jsonify({"week": week_sum(stu_id), "total": total_sum()}) + ws, we = week_range(dt.date.today()) + return jsonify({"week": week_sum(sid, ws, we), + "total": sum(week_sum(s.id, ws, we) for s in Student.query.all())}) +# ───── API: assign song ───── @app.post("/api/assign") @login_required def assign(): admin_required() - payload = request.get_json(force=True) - sid, tid = payload.get("student_id"), payload.get("song_id") + sid, tid = request.json.get("student_id"), request.json.get("song_id") if not (sid and tid): abort(400) db.session.merge(StudentSong(student_id=sid, song_id=tid)); db.session.commit() return "", 201 +# ───── API: add student ───── +@app.post("/api/student") +@login_required +def add_student(): + admin_required() + name = request.json.get("name", "").strip() + if not name: abort(400) + s = Student(name=name, parent_id=current_user.id) + db.session.add(s); db.session.commit(); return jsonify({"id": s.id, "name": s.name}), 201 + +# ───── API: add song ───── +@app.post("/api/song") +@login_required +def add_song(): + admin_required() + title = request.json.get("title", "").strip() + author = request.json.get("author", "").strip() + diff = int(request.json.get("difficulty", 1)) + if not title or not author: abort(400) + song = Song(title=title, author=author, difficulty=diff) + db.session.add(song); db.session.commit() + return jsonify({"id": song.id, "title": song.title, + "author": song.author, "difficulty": song.difficulty}), 201 + @app.get("/healthz") def health(): return "ok", 200 -@app.get("/slow-analysis") -def slow(): time.sleep(2); return "done", 200 - -# ───── seed ───── +# ───── Seed ───── with app.app_context(): db.create_all() if not User.query.first(): @@ -88,8 +132,10 @@ def slow(): time.sleep(2); return "done", 200 for n in ["Діана","Саша","Андріана","Маша","Ліза", "Кіріл","Остап","Єва","Валерія","Аня"]: db.session.add(Student(name=n, parent_id=admin.id)) - for t,d in [("Bluestone Alley",2),("Smells like teen spirit",1),("Horimia",3)]: - db.session.add(Song(title=t, difficulty=d)) + for t,a,d in [("Bluestone Alley","Chad Lawson",2), + ("Smells like teen spirit","Nirvana",1), + ("Horimia","Masaru Yokoyama",3)]: + db.session.add(Song(title=t, author=a, difficulty=d)) db.session.commit() if __name__ == "__main__": diff --git a/data/app.db b/data/app.db index 04ebcbe6af8bcc3a33fd01cf97f7c57ebcdbb179..d862bbae8fec872f7b85aaff53e14d18e7e1b87f 100644 GIT binary patch delta 709 zcmYk4yH6BR7{%|f>@LGT21p16L1rT+8ej9cuZaZ#VpdQVk;Q-w-nkDL8D?R3R@|7# z!_r68t>}ocvohP**x(;vWv9mfz!qtAVk6C$d?)Apa*DIN+qHYx^=de+0|4~b=J<#X zu5hhj9D|JN*;;h*JnMtFxptyZ z=n#P=mfVtDQGpp1iXhvAmb4{yEFz@OUCfX#A$7Pf2!xm@>0813VGQc9F?+He%^l=;$KZ*qpr&E9;lIvLK4Il{4#Ln(!xEg<9yV+&Ev9f$Z*_?$4y z#ey&!xxSk?p+zj>Vs25SEIX+uAW_It%teqQiW#Te=gen3@kq-jQaQF_Tq33%I>21V zV_djGDdi<~5$yaPE%fyq0OPyy(KzVzo6ih2l1hW&%i4`vJ=h4?ENifO^XPE7wq9$o z4VF6Fl?K;_^@R-?h7B_e)}5Nm z1@#Vsle?4LSv$D6h##O(aP<>Z5cDoD@9@0vyo--Zi^bBL-f}aFqUPJdsMm}xxj4;E zqegNZWrOTz`$Kw?c9Y}QNx8aWliFc)rWhx~xKaE{*NfN5_p04*sib+Zx!mY(Y{Z9K z#rxK);zBDe9;Ur!L$edDKfwPF-E2wA_q#>Rt!=1-mAw$VXH3jg& zX`u}zXem_;tki3u9OH)P9GUjo2O$d47yH6*x?Kv}cb;z3+kWT9kOD!?0l5Je2oHiW z85E*0;0@tENT~@k#3@kdIJJQ@Dy{OmGEgjdr6KUViX04eV2se-B0vRN2GUi|o_nW( zRae&lg5%NzK~2bW)QpsW`sqsZD9V0kKeD58b?`Ym*Q(W{UVMG_Z1QMgYrUm(Ryd>U1yc&S#Iq;L3CwUX=ev@!;K)dRf2sxBnl9IgTU% diff --git a/models.py b/models.py index 662f111..8b0d206 100644 --- a/models.py +++ b/models.py @@ -1,5 +1,6 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin +from sqlalchemy import UniqueConstraint db = SQLAlchemy() @@ -7,7 +8,7 @@ class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String, unique=True, nullable=False) password = db.Column(db.String, nullable=False) - role = db.Column(db.String, default="parent") # teacher | parent + role = db.Column(db.String, default="parent") # teacher | parent class Student(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -17,13 +18,16 @@ class Student(db.Model): class Song(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String, nullable=False) - difficulty = db.Column(db.Integer, default=1) # 1‑4 ★ + author = db.Column(db.String, nullable=False) + difficulty = db.Column(db.Integer, default=1) # 1‑4 ★ class Attendance(db.Model): id = db.Column(db.Integer, primary_key=True) student_id = db.Column(db.Integer, db.ForeignKey("student.id")) date = db.Column(db.Date, nullable=False) + __table_args__ = (UniqueConstraint("student_id", "date", + name="unique_attendance"),) class StudentSong(db.Model): student_id = db.Column(db.Integer, db.ForeignKey("student.id"), primary_key=True) - song_id = db.Column(db.Integer, db.ForeignKey("song.id"), primary_key=True) + song_id = db.Column(db.Integer, db.ForeignKey("song.id"), primary_key=True) diff --git a/static/app.js b/static/app.js index 8de62f4..10a6f80 100644 --- a/static/app.js +++ b/static/app.js @@ -2,28 +2,55 @@ document.addEventListener("DOMContentLoaded", () => { - /* lesson +/- кнопки (только у админа) */ - document.querySelectorAll(".add-btn").forEach(btn => { - btn.addEventListener("click", () => handleLesson(btn.dataset.id, "POST")); - }); - document.querySelectorAll(".del-btn").forEach(btn => { - btn.addEventListener("click", () => handleLesson(btn.dataset.id, "DELETE")); + /* ───────── журнал: кліки по комірках ───────── */ + document.querySelectorAll(".att").forEach(td => { + if (!td.dataset.stu) return; // у батьків атрибутів немає + td.addEventListener("click", async () => { + const payload = { student_id: td.dataset.stu, date: td.dataset.date }; + const r = await fetch("/api/attendance/toggle", { + method : "POST", + headers: { "Content-Type": "application/json" }, + body : JSON.stringify(payload) + }); + if (!r.ok) return; + const j = await r.json(); + td.classList.toggle("table-success"); + td.innerText = td.classList.contains("table-success") ? "✓" : ""; + td.parentElement.querySelector(".week-sum").innerText = j.week; + document.getElementById("total").innerText = j.total; + }); }); - async function handleLesson(id, method) { - const r = await fetch(`/api/attendance/${id}`, { method }); - if (!r.ok) return; - const j = await r.json(); - updateRow(id, j); + /* ───────── add student ───────── */ + const addStudent = document.getElementById("addStudent"); + if (addStudent){ + addStudent.addEventListener("submit", async e => { + e.preventDefault(); + const name = addStudent.elements.name.value.trim(); + if (!name) return; + const r = await fetch("/api/student", { + method:"POST", headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name}) + }); + if (r.ok) location.reload(); // простіше перезавантажити + }); } - function updateRow(id, j) { - const row = document.getElementById(`row-${id}`); - row.querySelector(".lessons").innerText = j.week / 130; - row.querySelector(".sum").innerText = j.week; - document.getElementById("total").innerText = `Усього: ${j.total} ₴`; + + /* ───────── add song ───────── */ + const addSong = document.getElementById("addSong"); + if (addSong){ + addSong.addEventListener("submit", async e => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(addSong)); + const r = await fetch("/api/song", { + method:"POST", headers:{'Content-Type':'application/json'}, + body:JSON.stringify(data) + }); + if (r.ok) location.reload(); + }); } - /* assign song (только у админа) */ + /* ───────── assign song ───────── */ document.querySelectorAll(".song-select").forEach(sel => { sel.addEventListener("change", async () => { const song = sel.dataset.songId; diff --git a/static/style.css b/static/style.css index c464401..860480d 100644 --- a/static/style.css +++ b/static/style.css @@ -1,6 +1,8 @@ body{ - background:linear-gradient(135deg,#e9f2ff 0%,#ffffff 100%); + background:linear-gradient(135deg,#eaf4ff 0%,#ffffff 100%); } [data-bs-theme="dark"] body{ background:linear-gradient(135deg,#2b2e36 0%,#202227 100%); } +.att{ cursor:pointer; } +.att.table-success{ font-weight:bold; } diff --git a/templates/index.html b/templates/index.html index a1b7f9a..9e9e640 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,57 +1,88 @@ {% extends "base.html" %} {% block content %} -

Відвідуваність та оплата

+

Журнал занять (поточний тиждень)

-
УченьУроки₴ / тижденьУченьУроки₴ / тиждень
{{ s.name }} {{ '%d' % (s.week / 130) }} {{ s.week }}
- Усього: {{ total }} ₴ + + Усього: {{ total }} ₴
- +
+ - - {% if current_user.role == 'teacher' %}{% endif %} + + {% for d in days %} + + {% endfor %} + {% for s in students %} - - - - {% if current_user.role == 'teacher' %} - + {% for d in days %} + {% set iso = d.isoformat() %} + - {% endif %} + {% endfor %} + {% endfor %} - - + + +
УченьУроки₴ / тижденьУчень{{ d.strftime('%a') }}
{{ d.strftime('%d.%m') }}
₴ / тиж.
{{ s.name }}{{ '%d' % (s.week / 130) }}{{ s.week }} - - + {{ s.name }} + {% if iso in attend[s.id] %}✓{% endif %} {{ s.week }}
- Усього: {{ total }} ₴ -
Усього:{{ total }}
-

Пісні

-
    +{% if current_user.role == 'teacher' %} +
    + +
    +

    Новий учень

    +
    +
    + + +
    +
    +
    + + +
    +

    Нова пісня

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +{% endif %} + +

    Список пісень

    +
      {% for t in songs %}
    • - {{ t.title }} -
      - {{ '★' * t.difficulty }} - {% if current_user.role == 'teacher' %} - - {% endif %} -
      + + {{ t.title }} — {{ t.author }} + {{ '★' * t.difficulty }} + + {% if current_user.role == 'teacher' %} + + {% endif %}
    • {% endfor %}
    From c820530b70bb5bf6e484f574e56027db84993db2 Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Mon, 16 Jun 2025 00:19:09 +0000 Subject: [PATCH 6/9] =?UTF-8?q?=20=D1=89=D0=B5=20=D1=82=D1=80=D0=BE=D1=85?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D1=80=D0=BE=D0=B1=D0=B8=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 131 +++++++++++++++++++++++----------------- data/app.db | Bin 36864 -> 36864 bytes static/app.js | 29 ++++----- templates/base.html | 24 +++++--- templates/journal.html | 51 ++++++++++++++++ templates/songs.html | 29 +++++++++ templates/students.html | 42 +++++++++++++ 7 files changed, 228 insertions(+), 78 deletions(-) create mode 100644 templates/journal.html create mode 100644 templates/songs.html create mode 100644 templates/students.html diff --git a/app.py b/app.py index ae6399c..ac5e2b9 100755 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ from __future__ import annotations -import pathlib, datetime as dt, time, logging -from flask import Flask, render_template, jsonify, abort, request +import pathlib, datetime as dt, time, logging, calendar +from flask import Flask, render_template, jsonify, abort, request, redirect, url_for from flask_login import LoginManager, login_required, current_user from werkzeug.security import generate_password_hash from models import db, User, Student, Song, Attendance, StudentSong @@ -16,58 +16,78 @@ ) db.init_app(app) -# ───── Login ───── +# ───── login ───── login = LoginManager(app); login.login_view = "auth.login" @login.user_loader def load_user(uid): return db.session.get(User, uid) -# ───── Blueprint auth ───── -from auth import bp as auth_bp # noqa: E402 +from auth import bp as auth_bp # noqa: E402 app.register_blueprint(auth_bp) # ───── helpers ───── -def week_range(today: dt.date) -> tuple[dt.date, dt.date]: - start = today - dt.timedelta(days=today.weekday()) # Monday - end = start + dt.timedelta(days=6) # Sunday - return start, end +def month_info(year: int, month: int): + days_in_month = calendar.monthrange(year, month)[1] + dates = [dt.date(year, month, d) for d in range(1, days_in_month + 1)] + return dates, dates[0], dates[-1] -def week_dates(today: dt.date) -> list[dt.date]: - s, _ = week_range(today); return [s + dt.timedelta(d) for d in range(7)] +def admin_required(): + if current_user.role != "teacher": + abort(403, "Тільки адміністратор може виконати дію") -def week_sum(stu_id: int, start: dt.date, end: dt.date) -> int: +def sum_for_student(stu_id: int, start: dt.date, end: dt.date) -> int: cnt = Attendance.query.filter_by(student_id=stu_id)\ .filter(Attendance.date.between(start, end)).count() return cnt * PRICE -def admin_required(): - if current_user.role != "teacher": - abort(403, "Тільки адміністратор може виконати дію") - -# ───── Routes ───── +# ───── routes ───── @app.get("/") +def root(): return redirect(url_for("journal")) + +# -------- журнал -------- +@app.get("/journal") @login_required -def index(): +def journal(): today = dt.date.today() - week_start, week_end = week_range(today) - days = week_dates(today) + y = int(request.args.get("y", today.year)) + m = int(request.args.get("m", today.month)) + + days, d_start, d_end = month_info(y, m) studs = (Student.query.all() if current_user.role == "teacher" else Student.query.filter_by(parent_id=current_user.id).all()) - # Attendance dict: {student_id: set("YYYY-MM-DD", ...)} attend = {s.id: set() for s in studs} - for a in Attendance.query.filter(Attendance.date.between(week_start, week_end)).all(): + for a in Attendance.query.filter(Attendance.date.between(d_start, d_end)).all(): attend.setdefault(a.student_id, set()).add(a.date.isoformat()) - # список студентов + недельная сумма - data = [{"id": s.id, "name": s.name, - "week": week_sum(s.id, week_start, week_end)} for s in studs] + rows = [{"id": s.id, + "name": s.name, + "month_sum": sum_for_student(s.id, d_start, d_end)} + for s in studs] + + return render_template("journal.html", + year=y, month=m, days=days, students=rows, attend=attend, + total=sum(r["month_sum"] for r in rows)) - return render_template("index.html", - students=data, days=days, attend=attend, price=PRICE, - total=sum(d["week"] for d in data), songs=Song.query.all()) +# -------- учні -------- +@app.get("/students") +@login_required +def students(): + studs = Student.query.all() if current_user.role == "teacher" \ + else Student.query.filter_by(parent_id=current_user.id).all() + catalog = Song.query.all() + mapping = {s.id: [ps.song for ps in StudentSong.query.filter_by(student_id=s.id).all()] + for s in studs} + return render_template("students.html", students=studs, + songs=catalog, mapping=mapping) + +# -------- пісні -------- +@app.get("/songs") +@login_required +def songs(): + return render_template("songs.html", songs=Song.query.all()) -# ───── API: attendance toggle ───── +# -------- API -------- @app.post("/api/attendance/toggle") @login_required def toggle_attendance(): @@ -75,26 +95,16 @@ def toggle_attendance(): sid = request.json.get("student_id") d = dt.date.fromisoformat(request.json.get("date")) row = Attendance.query.filter_by(student_id=sid, date=d).first() - if row: - db.session.delete(row) - else: - db.session.add(Attendance(student_id=sid, date=d)) + if row: db.session.delete(row) + else: db.session.add(Attendance(student_id=sid, date=d)) db.session.commit() - ws, we = week_range(dt.date.today()) - return jsonify({"week": week_sum(sid, ws, we), - "total": sum(week_sum(s.id, ws, we) for s in Student.query.all())}) -# ───── API: assign song ───── -@app.post("/api/assign") -@login_required -def assign(): - admin_required() - sid, tid = request.json.get("student_id"), request.json.get("song_id") - if not (sid and tid): abort(400) - db.session.merge(StudentSong(student_id=sid, song_id=tid)); db.session.commit() - return "", 201 + y, m = d.year, d.month + _, start, end = month_info(y, m) + month_sum = sum_for_student(sid, start, end) + total = db.session.query(Attendance.id).count() * PRICE + return jsonify({"month_sum": month_sum, "total": total}) -# ───── API: add student ───── @app.post("/api/student") @login_required def add_student(): @@ -102,39 +112,46 @@ def add_student(): name = request.json.get("name", "").strip() if not name: abort(400) s = Student(name=name, parent_id=current_user.id) - db.session.add(s); db.session.commit(); return jsonify({"id": s.id, "name": s.name}), 201 + db.session.add(s); db.session.commit() + return jsonify({"id": s.id, "name": s.name}), 201 -# ───── API: add song ───── @app.post("/api/song") @login_required def add_song(): admin_required() - title = request.json.get("title", "").strip() + title = request.json.get("title", "").strip() author = request.json.get("author", "").strip() - diff = int(request.json.get("difficulty", 1)) + diff = int(request.json.get("difficulty", 1)) if not title or not author: abort(400) song = Song(title=title, author=author, difficulty=diff) db.session.add(song); db.session.commit() - return jsonify({"id": song.id, "title": song.title, - "author": song.author, "difficulty": song.difficulty}), 201 + return jsonify({"id": song.id}), 201 + +@app.post("/api/assign") +@login_required +def assign(): + admin_required() + sid, tid = request.json.get("student_id"), request.json.get("song_id") + if not (sid and tid): abort(400) + db.session.merge(StudentSong(student_id=sid, song_id=tid)); db.session.commit() + return "", 201 @app.get("/healthz") def health(): return "ok", 200 -# ───── Seed ───── +# ───── seed (как прежде) ───── with app.app_context(): db.create_all() if not User.query.first(): admin = User(email="teacher@example.com", - password=generate_password_hash("secret"), - role="teacher") + password=generate_password_hash("secret"), role="teacher") db.session.add(admin); db.session.commit() for n in ["Діана","Саша","Андріана","Маша","Ліза", "Кіріл","Остап","Єва","Валерія","Аня"]: db.session.add(Student(name=n, parent_id=admin.id)) - for t,a,d in [("Bluestone Alley","Chad Lawson",2), + for t,a,d in [("Bluestone Alley","Chad Lawson",2), ("Smells like teen spirit","Nirvana",1), - ("Horimia","Masaru Yokoyama",3)]: + ("Horimia","Masaru Yokoyama",3)]: db.session.add(Song(title=t, author=a, difficulty=d)) db.session.commit() diff --git a/data/app.db b/data/app.db index d862bbae8fec872f7b85aaff53e14d18e7e1b87f..8d245f200dc0b1b15881b5133bfee440434cb1f8 100644 GIT binary patch delta 449 zcmWNN&1zFo6oqf1EfKL-1wlz67tBP8y3aoUDN?jm6yqN_a^ybe?4y;Kl$%7&sMWck zWY|eZPDL{EEqnssqu{{%0N+~cTSq7Dqm%ZxZmaWsbGOy`_4-G-UKi!|M!mXJ<&ATn zAGFttJ6~R0TmHFy^>7;N#ldVK(Hb{!1JQrAH+=c%-FQA6&EC{|!%uyclqDw_6KG|v z1Rn^6H^@2U-~@p8IV2an50OwCizH++0l}$|wDss1JYkH%lJc26qRc^}*7AnYXfSd} zGFl%6P)I3;ObzRV7S3=upqj_C<#zApp__bI@Z#Ci$L_xWXlL`ju*$O20)dQT0uimz z4Q*rel$vapwd7;~O_l*vvl0!H5;;pRIUBaX(SZVCxzS|P*eUj&L?C7fkkC>_@J5(; znNr|Hg@A>z5kXsf;j*H delta 477 zcmYk2KTi}v7>8#MZgnNHo`umI0w!1)aQ@G1h#?%s84OVygI2!t&O3lPB^+GLRiLen zW-Gd@tToqI^9lR{eg0?c?sNs<-W)?>u|A*BflF$S8%8-g{k%s5K^Qx$%fnxEuhDM5C9v8cyN@YNPuJ@Nq-xZtN^2m zDW$5ZHhOxwbo18rb5{N-f0gIWt6$1H_h;IyyP7>c93H$sfE_r7;pk#g|JYbtc#yT( zqlZ~ { - /* ───────── журнал: кліки по комірках ───────── */ + /** ───────── журнал ───────── **/ document.querySelectorAll(".att").forEach(td => { - if (!td.dataset.stu) return; // у батьків атрибутів немає + if (!td.dataset.stu) return; // у батьків немає атрибутів td.addEventListener("click", async () => { - const payload = { student_id: td.dataset.stu, date: td.dataset.date }; const r = await fetch("/api/attendance/toggle", { method : "POST", headers: { "Content-Type": "application/json" }, - body : JSON.stringify(payload) + body : JSON.stringify({ student_id: td.dataset.stu, + date: td.dataset.date }) }); if (!r.ok) return; const j = await r.json(); td.classList.toggle("table-success"); td.innerText = td.classList.contains("table-success") ? "✓" : ""; - td.parentElement.querySelector(".week-sum").innerText = j.week; - document.getElementById("total").innerText = j.total; + td.parentElement.querySelector(".month-sum").innerText = j.month_sum; + document.getElementById("total").innerText = j.total; }); }); - /* ───────── add student ───────── */ + /** ───────── add student ───────── **/ const addStudent = document.getElementById("addStudent"); if (addStudent){ addStudent.addEventListener("submit", async e => { @@ -32,11 +32,11 @@ document.addEventListener("DOMContentLoaded", () => { method:"POST", headers:{'Content-Type':'application/json'}, body:JSON.stringify({name}) }); - if (r.ok) location.reload(); // простіше перезавантажити + if (r.ok) location.reload(); }); } - /* ───────── add song ───────── */ + /** ───────── add song ───────── **/ const addSong = document.getElementById("addSong"); if (addSong){ addSong.addEventListener("submit", async e => { @@ -50,19 +50,20 @@ document.addEventListener("DOMContentLoaded", () => { }); } - /* ───────── assign song ───────── */ + /** ───────── assign song ───────── **/ document.querySelectorAll(".song-select").forEach(sel => { sel.addEventListener("change", async () => { - const song = sel.dataset.songId; - const stud = sel.value; - if (!stud) return; + const sid = sel.dataset.stu || sel.value; // у різних шаблонів свій атрибут + const tid = sel.dataset.songId || sel.value && sel.closest("li")?.dataset?.songId; + if (!(sid && tid)) return; await fetch("/api/assign", { method : "POST", headers: { "Content-Type": "application/json" }, - body : JSON.stringify({ student_id: stud, song_id: song }) + body : JSON.stringify({ student_id: sid, song_id: tid }) }); sel.selectedIndex = 0; }); }); }); + \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index ad12213..15dfcc5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,22 +2,32 @@ -{{ title|default('Music School') }} +{{ title|default('Music School') }} +
    {% with m = get_flashed_messages(with_categories=true) %} {% for cat,msg in m %} diff --git a/templates/journal.html b/templates/journal.html new file mode 100644 index 0000000..835f85f --- /dev/null +++ b/templates/journal.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block content %} + +

    Журнал – {{ days[0].strftime('%B %Y')|capitalize }}

    + + + + + + + + {% for d in days %} + + {% endfor %} + + + + + + {% for s in students %} + + + {% for d in days %} + {% set iso = d.isoformat() %} + + {% endfor %} + + + {% endfor %} + + + + + + + + +
    Учень{{ d.day }}₴ / міс.
    {{ s.name }} + {% if iso in attend[s.id] %}✓{% endif %} + {{ s.month_sum }}
    Усього:{{ total }}
    + + +{% endblock %} diff --git a/templates/songs.html b/templates/songs.html new file mode 100644 index 0000000..792e633 --- /dev/null +++ b/templates/songs.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block content %} + +

    Каталог пісень

    + +{% if current_user.role=='teacher' %} +
    +
    +
    +
    + +
    +
    +
    +{% endif %} + + + + + {% for s in songs %} + + {% endfor %} + +
    НазваАвторСкладність
    {{ s.title }}{{ s.author }}{{ '★'*s.difficulty }}
    + + +{% endblock %} diff --git a/templates/students.html b/templates/students.html new file mode 100644 index 0000000..c05a065 --- /dev/null +++ b/templates/students.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block content %} + +

    Учні

    + +{% if current_user.role=='teacher' %} +
    + + +
    +{% endif %} + + + + + {% for s in students %} + + + + + {% endfor %} + +
    Ім'яПісні
    {{ s.name }} + {% if mapping[s.id] %} + {% for ps in mapping[s.id] %} + {{ ps.title }} + {% endfor %} + {% else %}{% endif %} + + {% if current_user.role=='teacher' %} + + {% endif %} +
    + + +{% endblock %} From 7567cf0235d8c589d254970d30c9d667f6dc97ec Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Mon, 16 Jun 2025 00:53:52 +0000 Subject: [PATCH 7/9] =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D0=B7=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BE=20=D1=96=D0=B4=D0=B5=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 124 +++++++++++++++++++++++++++++++++------- auth.py | 33 +++++------ data/app.db | Bin 36864 -> 36864 bytes static/app.js | 40 ++++++++++--- static/style.css | 32 +++++++++-- templates/journal.html | 18 +++--- templates/songs.html | 20 ++++++- templates/students.html | 17 +++++- 8 files changed, 218 insertions(+), 66 deletions(-) diff --git a/app.py b/app.py index ac5e2b9..d1b5590 100755 --- a/app.py +++ b/app.py @@ -1,8 +1,9 @@ from __future__ import annotations -import pathlib, datetime as dt, time, logging, calendar +import pathlib, datetime as dt, calendar, logging from flask import Flask, render_template, jsonify, abort, request, redirect, url_for from flask_login import LoginManager, login_required, current_user from werkzeug.security import generate_password_hash +from sqlalchemy import delete from models import db, User, Student, Song, Attendance, StudentSong PRICE = 130 @@ -16,25 +17,26 @@ ) db.init_app(app) -# ───── login ───── +# ───── Login ───── login = LoginManager(app); login.login_view = "auth.login" @login.user_loader def load_user(uid): return db.session.get(User, uid) -from auth import bp as auth_bp # noqa: E402 +# ───── Blueprint auth ───── +from auth import bp as auth_bp # noqa: E402 app.register_blueprint(auth_bp) # ───── helpers ───── def month_info(year: int, month: int): - days_in_month = calendar.monthrange(year, month)[1] - dates = [dt.date(year, month, d) for d in range(1, days_in_month + 1)] + days_cnt = calendar.monthrange(year, month)[1] + dates = [dt.date(year, month, d) for d in range(1, days_cnt + 1)] return dates, dates[0], dates[-1] def admin_required(): if current_user.role != "teacher": abort(403, "Тільки адміністратор може виконати дію") -def sum_for_student(stu_id: int, start: dt.date, end: dt.date) -> int: +def month_sum(stu_id: int, start: dt.date, end: dt.date) -> int: cnt = Attendance.query.filter_by(student_id=stu_id)\ .filter(Attendance.date.between(start, end)).count() return cnt * PRICE @@ -62,12 +64,12 @@ def journal(): rows = [{"id": s.id, "name": s.name, - "month_sum": sum_for_student(s.id, d_start, d_end)} + "month_sum": month_sum(s.id, d_start, d_end)} for s in studs] return render_template("journal.html", - year=y, month=m, days=days, students=rows, attend=attend, - total=sum(r["month_sum"] for r in rows)) + year=y, month=m, days=days, students=rows, + attend=attend, total=sum(r["month_sum"] for r in rows)) # -------- учні -------- @app.get("/students") @@ -87,7 +89,7 @@ def students(): def songs(): return render_template("songs.html", songs=Song.query.all()) -# -------- API -------- +# ───── API ───── @app.post("/api/attendance/toggle") @login_required def toggle_attendance(): @@ -101,9 +103,9 @@ def toggle_attendance(): y, m = d.year, d.month _, start, end = month_info(y, m) - month_sum = sum_for_student(sid, start, end) + m_sum = month_sum(sid, start, end) total = db.session.query(Attendance.id).count() * PRICE - return jsonify({"month_sum": month_sum, "total": total}) + return jsonify({"month_sum": m_sum, "total": total}) @app.post("/api/student") @login_required @@ -115,6 +117,16 @@ def add_student(): db.session.add(s); db.session.commit() return jsonify({"id": s.id, "name": s.name}), 201 +@app.delete("/api/student/") +@login_required +def delete_student(sid): + admin_required() + db.session.execute(delete(StudentSong).where(StudentSong.student_id == sid)) + db.session.execute(delete(Attendance ).where(Attendance.student_id == sid)) + db.session.execute(delete(Student ).where(Student.id == sid)) + db.session.commit() + return "", 204 + @app.post("/api/song") @login_required def add_song(): @@ -127,6 +139,15 @@ def add_song(): db.session.add(song); db.session.commit() return jsonify({"id": song.id}), 201 +@app.delete("/api/song/") +@login_required +def delete_song(tid): + admin_required() + db.session.execute(delete(StudentSong).where(StudentSong.song_id == tid)) + db.session.execute(delete(Song).where(Song.id == tid)) + db.session.commit() + return "", 204 + @app.post("/api/assign") @login_required def assign(): @@ -139,20 +160,81 @@ def assign(): @app.get("/healthz") def health(): return "ok", 200 -# ───── seed (как прежде) ───── +# ───── сид‑дані ───── with app.app_context(): db.create_all() if not User.query.first(): admin = User(email="teacher@example.com", - password=generate_password_hash("secret"), role="teacher") + password=generate_password_hash("secret"), + role="teacher") db.session.add(admin); db.session.commit() - for n in ["Діана","Саша","Андріана","Маша","Ліза", - "Кіріл","Остап","Єва","Валерія","Аня"]: - db.session.add(Student(name=n, parent_id=admin.id)) - for t,a,d in [("Bluestone Alley","Chad Lawson",2), - ("Smells like teen spirit","Nirvana",1), - ("Horimia","Masaru Yokoyama",3)]: - db.session.add(Song(title=t, author=a, difficulty=d)) + + pupils = ["Діана","Саша","Андріана","Маша","Ліза","Кіріл","Остап", + "Єва","Валерія","Аня","Матвій","Валентин","Дем'ян", + "Єгор","Нікалай","Глєб","Георгій","Данило"] + students = {n: Student(name=n, parent_id=admin.id) for n in pupils} + db.session.add_all(students.values()) + + songs_data = [ + ("deluciuos of savior","Slayer",1), + ("Bluestone Alley","Wei Congfei",2), + ("memories and dreams","Sally Face",1), + ("come as you are","Nirvana",1), + ("smells like teen spirit","Nirvana",1), + ("Horimia","Масару Ёкояма",3), + ("falling down","Lil Peep",2), + ("sweet dreams","Marilyn Manson",1), + ("Chk chk boom","Stray Kids",3), + ("Щедрик","Микола Леонтович",2), + ("megalovania","Undertale",3), + ("feel good","Gorillaz",1), + ("Graze the roof","Plants vs Zombies",2), + ("смішні голоси","Ногу Свело",2), + ("маленький ковбой","Олександр Вінницький",3), + ("The Last of Us","G. Santaolalla",3), + ("носорігблюз","Юрій Радзецький",4), + ("enemy","Imagine Dragons",1), + ("Добрий вечір тобі","Народна",2), + ("червона калина","Степан Чарнецький",2), + ("snowdin town","Undertale",3), + ("7 nation army","The White Stripes",1), + ("Californication","RHCP",3), + ("polly","Nirvana",1), + ] + songs = {t: Song(title=t, author=a, difficulty=d) for t,a,d in songs_data} + db.session.add_all(songs.values()); db.session.commit() + + link = { + "Діана": ["feel good","deluciuos of savior","Graze the roof", + "Bluestone Alley","smells like teen spirit"], + "Саша": ["deluciuos of savior","memories and dreams", + "come as you are","smells like teen spirit"], + "Андріана": ["Bluestone Alley","Horimia","come as you are","falling down"], + "Маша": ["sweet dreams","smells like teen spirit","memories and dreams", + "Chk chk boom","come as you are"], + "Ліза": ["sweet dreams","Bluestone Alley","Horimia","Chk chk boom","Щедрик"], + "Кіріл": ["sweet dreams","megalovania","deluciuos of savior","feel good", + "Graze the roof","смішні голоси","falling down","маленький ковбой"], + "Остап": ["megalovania","Добрий вечір тобі","deluciuos of savior", + "червона калина","enemy","snowdin town","feel good", + "come as you are","sweet dreams","sweet dreams"], + "Єва": ["Bluestone Alley","deluciuos of savior","falling down"], + "Валерія":["sweet dreams","smells like teen spirit"], + "Аня": ["falling down","Californication","The Last of Us","Horimia", + "deluciuos of savior","memories and dreams","sweet dreams", + "come as you are","polly"], + "Валентин":["sweet dreams","deluciuos of savior","смішні голоси"], + "Дем'ян": ["sweet dreams"], + "Єгор": ["7 nation army","come as you are","Graze the roof", + "memories and dreams","megalovania","falling down"], + "Нікалай":["falling down","смішні голоси"], + "Глєб": ["смішні голоси","носорігблюз","The Last of Us"], + "Георгій":["маленький ковбой","носорігблюз","Bluestone Alley"], + } + for pupil, songlist in link.items(): + sid = students[pupil].id + for title in songlist: + db.session.add(StudentSong(student_id=sid, song_id=songs[title].id)) db.session.commit() if __name__ == "__main__": diff --git a/auth.py b/auth.py index 89ae4d0..c4388bb 100644 --- a/auth.py +++ b/auth.py @@ -7,7 +7,7 @@ from wtforms.validators import DataRequired, Email, Length, ValidationError bp = Blueprint("auth", __name__, url_prefix="/auth") -ADMIN_INVITE_CODE = "admin123" # измените при необходимости +ADMIN_INVITE_CODE = "admin123" # ───── WTForms ───── class LoginForm(FlaskForm): @@ -16,19 +16,20 @@ class LoginForm(FlaskForm): submit = SubmitField("Увійти") class RegisterForm(FlaskForm): - email = EmailField("Email", validators=[DataRequired(), Email()]) - password = PasswordField("Пароль", validators=[DataRequired(), Length(6)]) - role = RadioField("Роль", choices=[("parent", "Батьки / Учень"), - ("teacher", "Адмін")], - default="parent", validators=[DataRequired()]) - admin_code = PasswordField("Код для адміна") - submit = SubmitField("Зареєструватись") + email = EmailField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Пароль", validators=[DataRequired(), Length(6)]) + role = RadioField("Роль", + choices=[("parent","Батьки / Учень"), + ("teacher","Адмін")], + default="parent", validators=[DataRequired()]) + admin_code = PasswordField("Код для адміна") + submit = SubmitField("Зареєструватись") def validate_admin_code(form, field): if form.role.data == "teacher" and field.data != ADMIN_INVITE_CODE: raise ValidationError("Невірний admin‑код") -# ───── маршруты ───── +# ───── routes ───── @bp.route("/login", methods=["GET", "POST"]) def login(): form = LoginForm() @@ -36,7 +37,7 @@ def login(): user = User.query.filter_by(email=form.email.data).first() if user and check_password_hash(user.password, form.password.data): login_user(user) - return redirect(url_for("index")) + return redirect(url_for("journal")) flash("Невірний логін / пароль", "danger") return render_template("login.html", form=form) @@ -47,12 +48,12 @@ def register(): if User.query.filter_by(email=form.email.data).first(): flash("Email вже зареєстровано", "warning") else: - new_user = User(email=form.email.data, - password=generate_password_hash(form.password.data), - role=form.role.data) - db.session.add(new_user); db.session.commit() - flash("Успішно! Увійдіть під своїм логіном.", "success") - return redirect(url_for("auth.login")) + user = User(email=form.email.data, + password=generate_password_hash(form.password.data), + role=form.role.data) + db.session.add(user); db.session.commit() + login_user(user) # ← авто‑login + return redirect(url_for("journal")) return render_template("register.html", form=form) @bp.route("/logout") diff --git a/data/app.db b/data/app.db index 8d245f200dc0b1b15881b5133bfee440434cb1f8..8aee4bf20d03318d427af78e3d19ed94b55d0940 100644 GIT binary patch delta 1501 zcmYjROKjX!6n&l<2TW(|Nt%ArA1jKP!K^RRT}yURV!FM+s}#VjGfw( zl!=5U4OPnmQPiorfEJ}}P&a6jNksaYK`hv@c(drfV#5}R6=K8nP$;r&+4tT1?m6e4 z=ase8%37+~m+!lbq!Z%hRzIeqaCI`(hu&O0TWGN3dmD}Y{dKZ9ap_X+!tzouWuILp z?jcnz7ENLrC4+b+yJ)DgS~5-DQZ22hSh^}3u49&DN7hQjHNB!rs7r}ztCUcxX@=ra zUA9fxvx<~@u0j-ID#UOs)lhB6C^l?g3TTQ9jPH37Xk;JGHz zEW=hD&GBG?nyo)acI5TFS%RczQb+nq`b4@Y$r6oiGaT&}(6M@u9iLwk61bQd>4%Dxvyf$xy29|q^9 zCeEZsck}4TalybrFNR4}sl7i>@r8N6O7Uzp@)u}a*u|s6BU~JWS6n~9)$mGiK5%JN zC1sj^dyq#Hr@8J8w$)u>8+F!UZPvu>5o@x?Y&-d2_q(fXi~R_1ZOp!7O-O7*`t|PT zkhaYpru3aWni=ERZKz&{Rfu7>0Vy}Sce*PW%9HpVcCT04VVlXesi6U&dQ_kRt<+9d z$ebTgTz@Abb72r${XDWJImqjDufn|o)h)Kp9(TWBKd|q6-_T6gRo-zG*0c#@@QmO@F*?gxiEAm!^j5;%lmfl zXmo_D&^b~L!6rU=F;wLhnD{iu9zZVu4FDdoyS--u38wosEWn_9gV_KoH&U+(P;!8) zPt0G!4%~JaR=_``hNpcuPLI3{%VR=(h0-c^BT6dq3;`gu0MC#h4ugF!LBcTSfxLb& zhg~q)DZh-*P`Z$Mt&c~Aac&BbS9}6Unu(w=LlER@qIdUZGKusk&-3U+L5LeajSo*0 zGGP0H;Di;5NsMdZBG{WgKNAPjJkkyb6SC@)KBf_plnERX?fA{7sEI)M@*LdFq$os8jW%Yp#kwJl75$dm41Qq+mzO%FQseJ zg5*l)q&K9a(y%0me~G_|PvMXGK&$^Kt863b)N)VXed}*ZZ(m~o delta 478 zcmWNN&uUXa7{zaIXrL84Ra_`l8Zd|=;>4r$5wx*@LM9V{I~9_)9vpFx5M!{Yd`1r-bC965yk;~Qh!m2H)%wB9=DqBmlA6w_H-g$+o)x(3)pkJOYiei { - /** ───────── журнал ───────── **/ + /* ───────── Journal click ───────── */ document.querySelectorAll(".att").forEach(td => { - if (!td.dataset.stu) return; // у батьків немає атрибутів + if (!td.dataset.stu) return; td.addEventListener("click", async () => { const r = await fetch("/api/attendance/toggle", { method : "POST", headers: { "Content-Type": "application/json" }, body : JSON.stringify({ student_id: td.dataset.stu, - date: td.dataset.date }) + date : td.dataset.date }) }); if (!r.ok) return; const j = await r.json(); @@ -21,7 +21,7 @@ document.addEventListener("DOMContentLoaded", () => { }); }); - /** ───────── add student ───────── **/ + /* ───────── Add Student ───────── */ const addStudent = document.getElementById("addStudent"); if (addStudent){ addStudent.addEventListener("submit", async e => { @@ -36,7 +36,20 @@ document.addEventListener("DOMContentLoaded", () => { }); } - /** ───────── add song ───────── **/ + /* ───────── Delete Student ───────── */ + document.querySelectorAll(".del-stu").forEach(btn=>{ + btn.addEventListener("click", async ()=>{ + if(!confirm("Видалити учня разом із відвідуваністю?")) return; + const id=btn.dataset.id; + const r = await fetch(`/api/student/${id}`,{method:"DELETE"}); + if(r.ok){ + document.getElementById(`stu-${id}`)?.remove(); + document.getElementById(`row-${id}`)?.remove(); + } + }); + }); + + /* ───────── Add Song ───────── */ const addSong = document.getElementById("addSong"); if (addSong){ addSong.addEventListener("submit", async e => { @@ -50,11 +63,21 @@ document.addEventListener("DOMContentLoaded", () => { }); } - /** ───────── assign song ───────── **/ + /* ───────── Delete Song ───────── */ + document.querySelectorAll(".del-song").forEach(btn=>{ + btn.addEventListener("click", async ()=>{ + if(!confirm("Видалити пісню з каталогу?")) return; + const id=btn.dataset.id; + const r = await fetch(`/api/song/${id}`,{method:"DELETE"}); + if(r.ok) document.getElementById(`song-${id}`)?.remove(); + }); + }); + + /* ───────── Assign Song ───────── */ document.querySelectorAll(".song-select").forEach(sel => { sel.addEventListener("change", async () => { - const sid = sel.dataset.stu || sel.value; // у різних шаблонів свій атрибут - const tid = sel.dataset.songId || sel.value && sel.closest("li")?.dataset?.songId; + const sid = sel.dataset.stu; + const tid = sel.value; if (!(sid && tid)) return; await fetch("/api/assign", { method : "POST", @@ -66,4 +89,3 @@ document.addEventListener("DOMContentLoaded", () => { }); }); - \ No newline at end of file diff --git a/static/style.css b/static/style.css index 860480d..066d3d0 100644 --- a/static/style.css +++ b/static/style.css @@ -1,8 +1,32 @@ +/* journal cells */ +.att{ cursor:pointer; user-select:none; } +.att.table-success{ font-weight:bold; } + +/* CRUD buttons */ +.del-stu,.del-song,.edit-stu,.edit-song{ cursor:pointer; } + +/* cards */ +.card-panel{ + background:#ffffff; border-radius:12px; padding:24px; + box-shadow:0 4px 10px rgba(0,0,0,.07); +} +[data-bs-theme="dark"] .card-panel{ + background:#2b2e36; box-shadow:0 4px 10px rgba(0,0,0,.4); +} + +/* hovers */ +.table-hover>tbody>tr:hover>*{ + background-color:rgba(0,123,255,.08); +} +[data-bs-theme="dark"] .table-hover>tbody>tr:hover>*{ + background-color:rgba(0,123,255,.15); +} + +/* background */ body{ - background:linear-gradient(135deg,#eaf4ff 0%,#ffffff 100%); + background:linear-gradient(135deg,#eaf4ff 0%,#ffffff 60%,#e4edff 100%); + min-height:100vh; } [data-bs-theme="dark"] body{ - background:linear-gradient(135deg,#2b2e36 0%,#202227 100%); + background:linear-gradient(135deg,#1e1f24 0%,#202227 60%,#24262d 100%); } -.att{ cursor:pointer; } -.att.table-success{ font-weight:bold; } diff --git a/templates/journal.html b/templates/journal.html index 835f85f..ab35d57 100644 --- a/templates/journal.html +++ b/templates/journal.html @@ -1,26 +1,24 @@ {% extends "base.html" %} {% block content %} +

    Журнал – {{ days[0].strftime('%B %Y')|capitalize }}

    - +
    - {% for d in days %} - - {% endfor %} + {% for d in days %}{% endfor %} - {% for s in students %} @@ -38,7 +36,6 @@

    Журнал – {{ days[0].strftime('%B %Y')|capitalize }}

    {% endfor %} - @@ -47,5 +44,6 @@

    Журнал – {{ days[0].strftime('%B %Y')|capitalize }}

    Учень{{ d.day }}{{ d.day }}₴ / міс.
    Усього:
    +
    {% endblock %} diff --git a/templates/songs.html b/templates/songs.html index 792e633..4d1725f 100644 --- a/templates/songs.html +++ b/templates/songs.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block content %} +

    Каталог пісень

    @@ -16,14 +17,27 @@

    Каталог пісень

    {% endif %} - - +
    НазваАвторСкладність
    + + + {% for s in songs %} - + + + + {% endfor %}
    НазваАвторСкладність
    {{ s.title }}{{ s.author }}{{ '★'*s.difficulty }}
    {{ s.title }}{{ s.author }}{{ '★'*s.difficulty }} + {% if current_user.role=='teacher' %} + + + {% endif %} +
    +
    {% endblock %} diff --git a/templates/students.html b/templates/students.html index c05a065..da25edf 100644 --- a/templates/students.html +++ b/templates/students.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block content %} +

    Учні

    @@ -11,10 +12,12 @@

    Учні

    {% endif %} - + + + {% for s in students %} - + + {% endfor %}
    Ім'яПісні
    Ім'яПісні
    {{ s.name }} {% if mapping[s.id] %} @@ -22,7 +25,6 @@

    Учні

    {{ ps.title }} {% endfor %} {% else %}{% endif %} - {% if current_user.role=='teacher' %} {% endif %}
    + {% if current_user.role=='teacher' %} + + + {% endif %} +
    +
    {% endblock %} From 8070fc8e69f211ca74f4e58a165f15b983369d0c Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Mon, 16 Jun 2025 02:46:12 +0000 Subject: [PATCH 8/9] =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BB=D1=83=D1=87=D1=88=D0=B0=D1=8F=20=D1=81=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/app.db | Bin 36864 -> 36864 bytes static/css/journal.css | 5 +++ static/js/journal.js | 44 ++++++++++++++++++++++ templates/journal.html | 82 ++++++++++++++++++++--------------------- 4 files changed, 89 insertions(+), 42 deletions(-) create mode 100644 static/css/journal.css create mode 100644 static/js/journal.js diff --git a/data/app.db b/data/app.db index 8aee4bf20d03318d427af78e3d19ed94b55d0940..2e87a77034d67be25219cccc651a0bcdd34ea7a2 100644 GIT binary patch delta 377 zcmYk2y)MN;5XG-sO8BFa5ELF@KW1n4Utb>y#l0a>sLamH3K5MEs_PMm7Z7hCUW$rV zXH}f)oJ{hYlXL$(ynh}(tjx?mu8wBrUr$DB)7^uMz0`@W3C5$H3sVN|P)!w6z!8I4MXtJaw7IDNi#~L6+Ob}CV8G2S1M57i})@(!q z6na|Hnj6P*2#$a;QhtJUn9}yu2C7~u)z*91Fl}V+ramm^`aOOfu$TIo@0_}xhTVP2fG$N2og(7CC m@bBq%n_IbIO%zo|{S9^U~tk>^%yVs{>UG22Z5mOQs&a6Gx#0Z#0V!=Ww zl!(C1B!dBEZPF8}H&?9a>Q2O=dxR>`OLFfmXiy2tR*MI(tr@ zGo;|01lq`~(JV5>Y7&hBD|um{U=7r>*O(i+jO>xsM5%BMM%KsRa{3$2;56I(4&R&W Ee-A)mSO5S3 diff --git a/static/css/journal.css b/static/css/journal.css new file mode 100644 index 0000000..ed0f99a --- /dev/null +++ b/static/css/journal.css @@ -0,0 +1,5 @@ +/* робимо чекбокси більшими для зручності кліку */ +input.attend { + transform: scale(1.25); + cursor: pointer; +} \ No newline at end of file diff --git a/static/js/journal.js b/static/js/journal.js new file mode 100644 index 0000000..017b4a2 --- /dev/null +++ b/static/js/journal.js @@ -0,0 +1,44 @@ +document.addEventListener("DOMContentLoaded", () => { + const pricePerLesson = 130; // лише для відображення (бекенд рахує сам) + + /** Оновити комірку «сума» конкретного учня та загальний підсумок */ + function updateSums(rowElem, newRowSum, newTotal) { + rowElem.querySelector(".sum").textContent = newRowSum; + document.getElementById("grand-total").textContent = newTotal; + } + + /** Обробник кліку по чекбоксу */ + async function toggleAttendance(ev) { + const cb = ev.currentTarget; + const row = cb.closest("tr"); + const body = { + student_id: row.dataset.student, + date: cb.dataset.date + }; + + cb.disabled = true; // короткий захист від «двійного» кліку + + try { + const r = await fetch("/api/attendance/toggle", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }); + + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + updateSums(row, data.month_sum, data.total); + } catch (err) { + alert("Помилка збереження. Спробуйте ще раз."); + // повертаємо чекбокс у попередній стан + cb.checked = !cb.checked; + } finally { + cb.disabled = false; + } + } + + /** Навішуємо слухачів на усі чекбокси таблиці */ + document.querySelectorAll("input.attend").forEach(cb => { + cb.addEventListener("change", toggleAttendance); + }); +}); diff --git a/templates/journal.html b/templates/journal.html index ab35d57..ca783aa 100644 --- a/templates/journal.html +++ b/templates/journal.html @@ -1,49 +1,47 @@ {% extends "base.html" %} -{% block content %} -
    +{% block title %}Журнал відвідувань{% endblock %} -

    Журнал – {{ days[0].strftime('%B %Y')|capitalize }}

    +{% block content %} + + - +

    Журнал відвідувань – {{ month }} / {{ year }}

    - - - - - {% for d in days %}{% endfor %} - - - - - {% for s in students %} - - - {% for d in days %} - {% set iso = d.isoformat() %} - +
    +
    Учень{{ d.day }}₴ / міс.
    {{ s.name }} - {% if iso in attend[s.id] %}✓{% endif %} -
    + + + + {% for d in days %} + + {% endfor %} + + + + + {% for row in students %} + + + {% for d in days %} + {% set date_iso = d.isoformat() %} + {% set checked = date_iso in attend[row.id] %} + + {% endfor %} + + {% endfor %} - - - {% endfor %} - - - - - - - -
    Учень{{ d.day }}Сума грн
    {{ row.name }} + + {{ row.month_sum }}
    {{ s.month_sum }}
    Усього:{{ total }}
    - + + + + Загалом + {{ total }} + + +
    - {% endblock %} From 8742f7d82089b8d553ed0f52eeee6af7abe29c18 Mon Sep 17 00:00:00 2001 From: goldmonkeypunk Date: Mon, 16 Jun 2025 05:52:52 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=D0=B4=D0=BE=D0=B4=D0=B0=D1=8E=20workwlowS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/e2e_nochnyh_testiv.yml | 34 +++++++ .github/workflows/perevirka_kodu.yml | 89 +++++++++++++++++++ .github/workflows/prybyrannia_artefaktiv.yml | 35 ++++++++ data/app.db | Bin 36864 -> 36864 bytes static/js/students.js | 58 ++++++++++++ templates/students.html | 2 +- 6 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e_nochnyh_testiv.yml create mode 100644 .github/workflows/perevirka_kodu.yml create mode 100644 .github/workflows/prybyrannia_artefaktiv.yml create mode 100644 static/js/students.js diff --git a/.github/workflows/e2e_nochnyh_testiv.yml b/.github/workflows/e2e_nochnyh_testiv.yml new file mode 100644 index 0000000..83d5e6c --- /dev/null +++ b/.github/workflows/e2e_nochnyh_testiv.yml @@ -0,0 +1,34 @@ +name: 🌙 Інтеграційні тести (10× на добу) + +on: + schedule: + # кожні ~2 години ⇒ 12 разів; GitHub не підтримує точні 10, + # але це ближче за все до вимоги «десять» + - cron: '0 */2 * * *' + workflow_dispatch: + +jobs: + e2e: + name: 🚦 End‑to‑End тести + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: ▶️ Запускаємо docker‑compose + run: | + docker compose up -d + echo "⌛ Чекаємо 15 cекунд поки застосунок підійме службу…" + sleep 15 + + - name: 🔗 Перевірка доступності головної сторінки + run: | + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 || true) + if [ "$STATUS" != "200" ]; then + echo "❌ Головна недоступна, код $STATUS"; exit 1 + fi + echo "✅ Отримано 200 OK" + + - name: ⏹️ Зупинка контейнерів + if: always() + run: docker compose down -v diff --git a/.github/workflows/perevirka_kodu.yml b/.github/workflows/perevirka_kodu.yml new file mode 100644 index 0000000..8ee23f4 --- /dev/null +++ b/.github/workflows/perevirka_kodu.yml @@ -0,0 +1,89 @@ +name: ✅ Перевірка коду (CI) + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + workflow_dispatch: # ручний запуск + +jobs: + lint: + name: 🔍 Лінтинг (flake8) + runs-on: ubuntu-latest + steps: + - name: ⬇️ Клон репозиторію + uses: actions/checkout@v4 + + - name: ⚙️ Встановити Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: 📦 Встановити залежності + run: | + pip install -r requirements.txt flake8 + + - name: 🚨 Запустити flake8 + run: | + echo "▶️ Старт лінтингу…" + flake8 . + echo "✅ Лінтинг завершено без помилок" + + types: + name: 🧐 Статична перевірка типів (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.12' } + - run: | + pip install -r requirements.txt mypy + - name: 🔍 mypy + run: | + echo "▶️ Перевіряємо типи…" + mypy . + echo "✅ Типи коректні" + + tests: + name: 🧪 Модульні тести (pytest) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: testdb + ports: [ '5432:5432' ] + options: >- + --health-cmd "pg_isready -U test" --health-interval 10s + --health-timeout 5s --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.12' } + - run: | + pip install -r requirements.txt pytest coverage + - name: ▶️ Запуск pytest + run: | + coverage run -m pytest -v + - name: 📊 Звіт Coverage + run: | + coverage xml -o coverage.xml + - name: ☁️ Завантажити coverage у Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + build-image: + name: 🐳 Локальна збірка Docker‑образу + runs-on: ubuntu-latest + needs: [ lint, types, tests ] + steps: + - uses: actions/checkout@v4 + - name: ▶️ docker build + run: | + echo "🛠️ Будуємо контейнер…" + docker build -t myapp:test . + echo "✅ Збірка пройшла успішно" diff --git a/.github/workflows/prybyrannia_artefaktiv.yml b/.github/workflows/prybyrannia_artefaktiv.yml new file mode 100644 index 0000000..8eb13a7 --- /dev/null +++ b/.github/workflows/prybyrannia_artefaktiv.yml @@ -0,0 +1,35 @@ +name: 🧹 Прибирання артефактів + +on: + schedule: + # 5 разів на добу (через кожні 4 години 48 хвилин) + - cron: '48 */4 * * *' + workflow_dispatch: + +jobs: + cleanup: + name: 🗑️ Видалення старих artefact‑ів + runs-on: ubuntu-latest + + steps: + - name: 🧮 Знаходимо застарілі artefact‑и + uses: actions/github-script@v7 + with: + script: | + const maxAgeDays = 3 + const cutoff = Date.now() - maxAgeDays*24*60*60*1000 + const { data } = await github.rest.actions.listArtifactsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }) + const old = data.artifacts.filter(a => new Date(a.created_at).valueOf() < cutoff) + core.notice(`Старих artefact‑ів: ${old.length}`) + for (const art of old) { + core.notice(`🗑️ Видалення #${art.id} (${art.name})`) + await github.rest.actions.deleteArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: art.id + }) + } diff --git a/data/app.db b/data/app.db index 2e87a77034d67be25219cccc651a0bcdd34ea7a2..208efaa4352fc23f93ca3b5bf1e5fbdbd3a7b890 100644 GIT binary patch delta 330 zcmWlUJxYWz5J1_jwi9d>*J2Maf0-nuuGolRrC?<;GXX_G5Vh1JC|*Fkf$Zh9^YZ}j z@!sp_{PlDGwliD2@2+Qy@2mCR%hL_Z@j-t)yEwXhJ~@ZgP^yKY_bQAeqqNQ=M-g(! zDz#=$i6x^|LYKr0-Z&bvj4T?t#y}*;$xt#WWt+g#q$JAW>ZYQTzgkHZw#Ai9IkjY( z3u5SpEam0&8V}{<7ESKDT|B@FLIzy#l0a>sLamH3K5MEs_PMm7Z7hCUW$rV zXH}f)oJ{hYlXL$(ynh}(tjx?mu8wBrUr$DB)7^uMz0`@W3C5$H3sVN|P)!w6z!8I4MXtJaw7IDNi#~L6+Ob}CV8G2S1M57i})@(!q z6na|Hnj6P*2#$a;QhtJUn9}yu2C7~u)z*91Fl}V+ramm^`aOOfu$TIo@0_}xhTVP2fG$N2og(7CC m@b { + /* ---- службові словники ---- */ + const songTitles = JSON.parse( + document.getElementById("song-titles").textContent + ); + + /** Оновити рядок зведеної таблиці для конкретного учня */ + function updateSummaryRow(studentId) { + const checked = document.querySelectorAll( + `#assign-table tr[data-sid="${studentId}"] input.assign-box:checked` + ); + const titles = Array.from(checked, (cb) => songTitles[cb.dataset.tid]); + const cell = document.querySelector( + `#summary-table tr[data-sid="${studentId}"] .songs-list` + ); + cell.textContent = titles.join(", "); + } + + /** Відправити на бекенд assign / unassign і після успіху оновити таблицю */ + async function toggleAssign(ev) { + const box = ev.currentTarget; + const studentId = box.dataset.sid; + const songId = box.dataset.tid; + const url = box.checked + ? "/api/assign" + : `/api/unassign/${studentId}/${songId}`; + const options = box.checked + ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ student_id: studentId, song_id: songId }) + } + : { method: "DELETE" }; + + box.disabled = true; + try { + const r = await fetch(url, options); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + updateSummaryRow(studentId); + } catch (err) { + alert("Помилка збереження! Спробуйте пізніше."); + box.checked = !box.checked; // повертаємо стан назад + } finally { + box.disabled = false; + } + } + + /** Навісити слухачів на всі чекбокси */ + document.querySelectorAll("input.assign-box").forEach((box) => { + box.addEventListener("change", toggleAssign); + }); +}); diff --git a/templates/students.html b/templates/students.html index da25edf..76e86d7 100644 --- a/templates/students.html +++ b/templates/students.html @@ -50,4 +50,4 @@

    Учні

    -{% endblock %} + {% endblock %}