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/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..d1b5590 100755 --- a/app.py +++ b/app.py @@ -1,15 +1,242 @@ -#!/usr/bin/env python3 +from __future__ import annotations +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 -from flask import Flask,request,render_template -from json import loads,dumps,load +PRICE = 130 +DATA_DIR = pathlib.Path("/data"); DATA_DIR.mkdir(parents=True, exist_ok=True) -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:///{DATA_DIR/'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 ───── +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 +app.register_blueprint(auth_bp) + +# ───── helpers ───── +def month_info(year: int, month: int): + 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 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 + +# ───── routes ───── +@app.get("/") +def root(): return redirect(url_for("journal")) + +# -------- журнал -------- +@app.get("/journal") +@login_required +def journal(): + today = dt.date.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()) + + attend = {s.id: set() for s in studs} + for a in Attendance.query.filter(Attendance.date.between(d_start, d_end)).all(): + attend.setdefault(a.student_id, set()).add(a.date.isoformat()) + + rows = [{"id": s.id, + "name": s.name, + "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)) + +# -------- учні -------- +@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 ───── +@app.post("/api/attendance/toggle") +@login_required +def toggle_attendance(): + admin_required() + 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() + + y, m = d.year, d.month + _, start, end = month_info(y, m) + m_sum = month_sum(sid, start, end) + total = db.session.query(Attendance.id).count() * PRICE + return jsonify({"month_sum": m_sum, "total": total}) + +@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 + +@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(): + 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}), 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(): + 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 + +# ───── сид‑дані ───── +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() + + 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__": + logging.basicConfig(level=logging.INFO) + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..c4388bb --- /dev/null +++ b/auth.py @@ -0,0 +1,63 @@ +from flask import Blueprint, render_template, redirect, url_for, flash +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, RadioField +from wtforms.validators import DataRequired, Email, Length, ValidationError + +bp = Blueprint("auth", __name__, url_prefix="/auth") +ADMIN_INVITE_CODE = "admin123" + +# ───── WTForms ───── +class LoginForm(FlaskForm): + email = EmailField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Пароль", validators=[DataRequired(), Length(6)]) + 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‑код") + +# ───── routes ───── +@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("journal")) + 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: + 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") +@login_required +def logout(): + logout_user() + return redirect(url_for("auth.login")) diff --git a/data/app.db b/data/app.db new file mode 100644 index 0000000..208efaa Binary files /dev/null and b/data/app.db differ diff --git a/docker-compose.yml b/docker-compose.yml index 950eefa..db2ea5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,29 @@ -version: "3.5" - services: - web-example: - build: - context: . - expose: - - 5000 - volumes: - - .:/app:ro - command: /usr/local/bin/gunicorn -k gevent --reload --workers 10 --worker-connections 10 --access-logfile=- --pythonpath /app -b :5000 app:app - - nginx: - restart: always - image: nginx:latest - ports: - - "8080:8080" - volumes: - - ./static:/www/static:ro - - ./etc/nginx.conf:/etc/nginx/conf.d/default.conf - + 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: . + dockerfile: Dockerfile + expose: + - "5000" + command: + - /usr/local/bin/gunicorn + - -k + - gevent + - --bind + - 0.0.0.0:5000 + - app:app + volumes: + - ./:/app:ro # код read‑only + - ./data:/data # БД read‑write + restart: unless-stopped 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 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 new file mode 100644 index 0000000..8b0d206 --- /dev/null +++ b/models.py @@ -0,0 +1,33 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from sqlalchemy import UniqueConstraint + +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) + 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) diff --git a/requirements.txt b/requirements.txt index fe9fe10..03e238c 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 +email-validator==2.1.1 +gunicorn==22.0.0 +gevent==24.2.1 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..555878e --- /dev/null +++ b/static/app.js @@ -0,0 +1,91 @@ +/* global fetch */ + +document.addEventListener("DOMContentLoaded", () => { + + /* ───────── Journal click ───────── */ + document.querySelectorAll(".att").forEach(td => { + 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 }) + }); + if (!r.ok) return; + const j = await r.json(); + td.classList.toggle("table-success"); + td.innerText = td.classList.contains("table-success") ? "✓" : ""; + td.parentElement.querySelector(".month-sum").innerText = j.month_sum; + document.getElementById("total").innerText = j.total; + }); + }); + + /* ───────── 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(); + }); + } + + /* ───────── 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 => { + 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(); + }); + } + + /* ───────── 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; + const tid = sel.value; + if (!(sid && tid)) return; + await fetch("/api/assign", { + method : "POST", + headers: { "Content-Type": "application/json" }, + body : JSON.stringify({ student_id: sid, song_id: tid }) + }); + sel.selectedIndex = 0; + }); + }); + +}); 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/static/js/students.js b/static/js/students.js new file mode 100644 index 0000000..43026c3 --- /dev/null +++ b/static/js/students.js @@ -0,0 +1,58 @@ +/** + * students.js + * – перемикає призначення пісні учню по чекбоксу + * – он‑лайн оновлює зведену таблицю «Вивчені твори» + */ + +document.addEventListener("DOMContentLoaded", () => { + /* ---- службові словники ---- */ + 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/static/style.css b/static/style.css new file mode 100644 index 0000000..066d3d0 --- /dev/null +++ b/static/style.css @@ -0,0 +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 60%,#e4edff 100%); + min-height:100vh; +} +[data-bs-theme="dark"] body{ + background:linear-gradient(135deg,#1e1f24 0%,#202227 60%,#24262d 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..15dfcc5 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,39 @@ + + + + +{{ 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..9e9e640 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block content %} + +

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

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

Новий учень

+
+
+ + +
+
+
+ + +
+

Нова пісня

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

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

+ + + +{% endblock %} diff --git a/templates/journal.html b/templates/journal.html new file mode 100644 index 0000000..ca783aa --- /dev/null +++ b/templates/journal.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Журнал відвідувань{% endblock %} + +{% block content %} + + + +

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

+ +
+ + + + + {% 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 %} + + + + + + + +
Учень{{ d.day }}Сума грн
{{ row.name }} + + {{ row.month_sum }}
Загалом{{ total }}
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..432e48f --- /dev/null +++ b/templates/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +

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

+ +
+ {{ 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 new file mode 100644 index 0000000..e50fd7e --- /dev/null +++ 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 %} diff --git a/templates/songs.html b/templates/songs.html new file mode 100644 index 0000000..4d1725f --- /dev/null +++ b/templates/songs.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block content %} +
+ +

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

+ +{% if current_user.role=='teacher' %} +
+
+
+
+ +
+
+
+{% endif %} + + + + + + + {% for s in songs %} + + + + + {% endfor %} + +
НазваАвторСкладність
{{ s.title }}{{ s.author }}{{ '★'*s.difficulty }} + {% if current_user.role=='teacher' %} + + + {% endif %} +
+ +
+ +{% endblock %} diff --git a/templates/students.html b/templates/students.html new file mode 100644 index 0000000..76e86d7 --- /dev/null +++ b/templates/students.html @@ -0,0 +1,53 @@ +{% 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 %} + + {% if current_user.role=='teacher' %} + + + {% endif %} +
+ +
+ + {% endblock %}