diff --git a/.env.example b/.env.example index bfeedaa..cd14689 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ ADMIN_PASSWORD=change_me_admin_password # Default user group quota in MB (0 = unlimited) DEFAULT_GROUP_QUOTA_MB=100 +DEFAULT_GROUP_MAX_FOLDERS=10 +DEFAULT_GROUP_MAX_PHOTOS=500 # Git deploy from admin panel (requires repo mount and docker socket) ALLOW_GIT_DEPLOY=false diff --git a/README.md b/README.md index 161197c..4c546ab 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,8 @@ docker compose up -d --build | `/cabinet/profile` | Настройки профиля, смена пароля | | `/admin/` | Панель администратора (только admin) | | `/admin/users` | Управление пользователями | +| `/admin/groups` | Группы: квота диска, лимиты папок и фото | +| `/admin/banners` | Рекламные баннеры на сайте | | `/admin/photos` | Все фото на сервере | **Права доступа:** @@ -321,6 +323,42 @@ docker compose up -d --build --- +## Релиз v1.4 + +**Лимиты групп пользователей** + +- В `/admin/groups` администратор задаёт для каждой группы: + - **Квота диска** (МБ, `0` = без лимита) + - **Максимум папок** на пользователя (`0` = без лимита) + - **Максимум фото** на пользователя (`0` = без лимита) +- Лимиты проверяются при создании папки и загрузке фото +- В личном кабинете отображается использование квот + +**Переменные `.env` для группы по умолчанию:** + +```env +DEFAULT_GROUP_QUOTA_MB=100 +DEFAULT_GROUP_MAX_FOLDERS=10 +DEFAULT_GROUP_MAX_PHOTOS=500 +``` + +**Рекламные баннеры** + +- Управление в `/admin/banners` +- Позиции: главная (под hero), личный кабинет, подвал +- URL изображения, опциональная ссылка при клике, порядок сортировки, вкл/выкл + +**Обновление до v1.4 на сервере:** + +```bash +cd ~/fotohost +git fetch --tags +git checkout v1.4 +docker compose up -d --build +``` + +--- + ## Полезные команды | Действие | Команда | @@ -449,6 +487,7 @@ python wsgi.py | POST | `/auth/login` | Вход | | GET | `/cabinet/` | Личный кабинет | | GET | `/admin/` | Админ-панель | +| GET | `/admin/banners` | Управление рекламными баннерами | | POST | `/upload` | Загрузка фото (auth) | | GET | `/uploads/` | Прямая ссылка на файл | | GET | `/api/photos` | JSON-список всех фото | diff --git a/app/__init__.py b/app/__init__.py index e232215..123724d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -59,8 +59,15 @@ def create_app(): register_cli(app) + @app.context_processor + def inject_banners(): + from app.banner_service import get_banners_by_position + + return {"site_banners": get_banners_by_position()} + with app.app_context(): from app.models import ( # noqa: F401 + AdBanner, Folder, FolderInvite, FolderMember, @@ -75,6 +82,7 @@ def create_app(): from app.bootstrap import ( create_first_admin, ensure_default_group, + ensure_group_limit_columns, ensure_photo_storage_column, ensure_schema, ensure_site_settings, @@ -83,6 +91,7 @@ def create_app(): ensure_schema() ensure_default_group(app) + ensure_group_limit_columns() ensure_folder_schema() ensure_site_settings(app) ensure_photo_storage_column() diff --git a/app/admin.py b/app/admin.py index 68d18db..50f4383 100644 --- a/app/admin.py +++ b/app/admin.py @@ -15,8 +15,8 @@ from app.deploy_utils import ( get_deploy_status, is_deploy_enabled, ) -from app.models import Photo, User, UserGroup -from app.quota_utils import get_user_storage_used +from app.models import AdBanner, Photo, User, UserGroup +from app.quota_utils import get_user_folder_count, get_user_photo_count, get_user_storage_used from app.settings_service import get_settings, update_settings_from_form from app.storage_service import delete_photo_file @@ -131,6 +131,12 @@ def groups(): if request.method == "POST": name = request.form.get("name", "").strip() quota_mb = request.form.get("disk_quota_mb", type=int) or 100 + max_folders = request.form.get("max_folders", type=int) + max_photos = request.form.get("max_photos", type=int) + if max_folders is None: + max_folders = 10 + if max_photos is None: + max_photos = 500 if len(name) < 2: flash("Название группы — минимум 2 символа", "error") @@ -144,7 +150,13 @@ def groups(): slug = f"{base_slug}-{counter}" counter += 1 - group = UserGroup(name=name, slug=slug, disk_quota_mb=max(0, quota_mb)) + group = UserGroup( + name=name, + slug=slug, + disk_quota_mb=max(0, quota_mb), + max_folders=max(0, max_folders), + max_photos=max(0, max_photos), + ) db.session.add(group) db.session.commit() flash(f"Группа «{name}» создана", "success") @@ -154,7 +166,14 @@ def groups(): group_stats = [] for group in all_groups: used = sum(get_user_storage_used(u.id) for u in group.users) - group_stats.append({"group": group, "storage_used": used}) + photos = sum(get_user_photo_count(u.id) for u in group.users) + folders = sum(get_user_folder_count(u.id) for u in group.users) + group_stats.append({ + "group": group, + "storage_used": used, + "photo_count": photos, + "folder_count": folders, + }) return render_template("admin/groups.html", group_stats=group_stats) @@ -164,6 +183,8 @@ def edit_group(group_id): group = UserGroup.query.get_or_404(group_id) name = request.form.get("name", "").strip() quota_mb = request.form.get("disk_quota_mb", type=int) + max_folders = request.form.get("max_folders", type=int) + max_photos = request.form.get("max_photos", type=int) if len(name) < 2: flash("Название группы — минимум 2 символа", "error") @@ -177,6 +198,10 @@ def edit_group(group_id): group.name = name if quota_mb is not None: group.disk_quota_mb = max(0, quota_mb) + if max_folders is not None: + group.max_folders = max(0, max_folders) + if max_photos is not None: + group.max_photos = max(0, max_photos) db.session.commit() flash(f"Группа «{group.name}» обновлена", "success") return redirect(url_for("admin.groups")) @@ -202,6 +227,97 @@ def delete_group(group_id): return redirect(url_for("admin.groups")) +@bp.route("/banners", methods=["GET", "POST"]) +@admin_required +def banners(): + if request.method == "POST": + title = request.form.get("title", "").strip() + image_url = request.form.get("image_url", "").strip() + link_url = request.form.get("link_url", "").strip() or None + alt_text = request.form.get("alt_text", "").strip() or None + position = request.form.get("position", "main").strip() + sort_order = request.form.get("sort_order", type=int) or 0 + is_active = request.form.get("is_active") == "on" + + if len(title) < 2: + flash("Название баннера — минимум 2 символа", "error") + elif not image_url: + flash("Укажите URL изображения", "error") + elif position not in AdBanner.POSITIONS: + flash("Неверная позиция баннера", "error") + else: + banner = AdBanner( + title=title, + image_url=image_url, + link_url=link_url, + alt_text=alt_text or title, + position=position, + sort_order=sort_order, + is_active=is_active, + ) + db.session.add(banner) + db.session.commit() + flash(f"Баннер «{title}» добавлен", "success") + return redirect(url_for("admin.banners")) + + all_banners = AdBanner.query.order_by(AdBanner.position, AdBanner.sort_order, AdBanner.id).all() + return render_template("admin/banners.html", banners=all_banners, positions=AdBanner.POSITIONS) + + +@bp.route("/banners//edit", methods=["POST"]) +@admin_required +def edit_banner(banner_id): + banner = AdBanner.query.get_or_404(banner_id) + title = request.form.get("title", "").strip() + image_url = request.form.get("image_url", "").strip() + link_url = request.form.get("link_url", "").strip() or None + alt_text = request.form.get("alt_text", "").strip() or None + position = request.form.get("position", banner.position).strip() + sort_order = request.form.get("sort_order", type=int) + is_active = request.form.get("is_active") == "on" + + if len(title) < 2: + flash("Название баннера — минимум 2 символа", "error") + elif not image_url: + flash("Укажите URL изображения", "error") + elif position not in AdBanner.POSITIONS: + flash("Неверная позиция баннера", "error") + else: + banner.title = title + banner.image_url = image_url + banner.link_url = link_url + banner.alt_text = alt_text or title + banner.position = position + if sort_order is not None: + banner.sort_order = sort_order + banner.is_active = is_active + db.session.commit() + flash(f"Баннер «{banner.title}» обновлён", "success") + + return redirect(url_for("admin.banners")) + + +@bp.route("/banners//delete", methods=["POST"]) +@admin_required +def delete_banner(banner_id): + banner = AdBanner.query.get_or_404(banner_id) + db.session.delete(banner) + db.session.commit() + flash("Баннер удалён", "success") + return redirect(url_for("admin.banners")) + + +@bp.route("/banners//toggle", methods=["POST"]) +@admin_required +def toggle_banner(banner_id): + banner = AdBanner.query.get_or_404(banner_id) + banner.is_active = not banner.is_active + db.session.commit() + state = "включён" if banner.is_active else "выключен" + flash(f"Баннер «{banner.title}» {state}", "success") + return redirect(url_for("admin.banners")) + + @bp.route("/photos") @admin_required def photos(): diff --git a/app/banner_service.py b/app/banner_service.py new file mode 100644 index 0000000..70e6118 --- /dev/null +++ b/app/banner_service.py @@ -0,0 +1,16 @@ +from app.models import AdBanner + + +def get_banners(position=None): + query = AdBanner.query.filter_by(is_active=True).order_by(AdBanner.sort_order, AdBanner.id) + if position: + query = query.filter_by(position=position) + return query.all() + + +def get_banners_by_position(): + banners = get_banners() + grouped = {} + for banner in banners: + grouped.setdefault(banner.position, []).append(banner) + return grouped diff --git a/app/bootstrap.py b/app/bootstrap.py index b198734..5bf17c1 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -30,6 +30,8 @@ def ensure_schema(): def ensure_default_group(app): default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100")) + default_max_folders = int(os.getenv("DEFAULT_GROUP_MAX_FOLDERS", "10")) + default_max_photos = int(os.getenv("DEFAULT_GROUP_MAX_PHOTOS", "500")) default_group = UserGroup.query.filter_by(is_default=True).first() if not default_group: @@ -41,16 +43,41 @@ def ensure_default_group(app): name="Пользователи", slug="users", disk_quota_mb=default_quota, + max_folders=default_max_folders, + max_photos=default_max_photos, is_default=True, ) db.session.add(default_group) db.session.commit() - app.logger.info("Default user group 'users' created with %s MB quota", default_quota) + app.logger.info( + "Default user group 'users' created (quota=%s MB, folders=%s, photos=%s)", + default_quota, + default_max_folders, + default_max_photos, + ) User.query.filter(User.group_id.is_(None)).update({"group_id": default_group.id}) db.session.commit() +def ensure_group_limit_columns(): + inspector = inspect(db.engine) + if "user_groups" not in inspector.get_table_names(): + return + + columns = {col["name"] for col in inspector.get_columns("user_groups")} + if "max_folders" not in columns: + db.session.execute( + text("ALTER TABLE user_groups ADD COLUMN max_folders INTEGER NOT NULL DEFAULT 10") + ) + db.session.commit() + if "max_photos" not in columns: + db.session.execute( + text("ALTER TABLE user_groups ADD COLUMN max_photos INTEGER NOT NULL DEFAULT 500") + ) + db.session.commit() + + def ensure_site_settings(app): from app.models import SiteSettings diff --git a/app/folders.py b/app/folders.py index 09d53f7..cbe5de3 100644 --- a/app/folders.py +++ b/app/folders.py @@ -22,6 +22,7 @@ from app.folder_utils import ( unlock_folder, ) from app.models import Folder, FolderInvite, FolderMember, Photo, User +from app.quota_utils import check_folder_limit from app.settings_service import get_settings from app.storage_service import delete_photo_file @@ -31,6 +32,8 @@ bp = Blueprint("folders", __name__) @bp.route("/cabinet/folders") @login_required def list_folders(): + from app.quota_utils import quota_status + process_pending_invites(current_user) owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all() shared = ( @@ -42,7 +45,12 @@ def list_folders(): .order_by(Folder.created_at.desc()) .all() ) - return render_template("cabinet/folders/list.html", owned_folders=owned, shared_folders=shared) + return render_template( + "cabinet/folders/list.html", + owned_folders=owned, + shared_folders=shared, + quota=quota_status(current_user), + ) @bp.route("/cabinet/folders/create", methods=["POST"]) @@ -56,6 +64,11 @@ def create_folder(): flash("Название папки — минимум 2 символа", "error") return redirect(url_for("folders.list_folders")) + ok, limit_msg = check_folder_limit(current_user) + if not ok: + flash(limit_msg, "error") + return redirect(url_for("folders.list_folders")) + folder = Folder(name=name, owner_id=current_user.id, is_private=is_private) if access_password: if len(access_password) < 4: diff --git a/app/models.py b/app/models.py index 0a03105..143a497 100644 --- a/app/models.py +++ b/app/models.py @@ -133,6 +133,8 @@ class UserGroup(db.Model): name = db.Column(db.String(80), unique=True, nullable=False) slug = db.Column(db.String(80), unique=True, nullable=False, index=True) disk_quota_mb = db.Column(db.Integer, nullable=False, default=100) + max_folders = db.Column(db.Integer, nullable=False, default=10) + max_photos = db.Column(db.Integer, nullable=False, default=500) is_default = db.Column(db.Boolean, nullable=False, default=False) created_at = db.Column( db.DateTime, @@ -150,6 +152,47 @@ class UserGroup(db.Model): return "Без лимита" return f"{self.disk_quota_mb} МБ" + @property + def folders_limit_label(self): + if self.max_folders == 0: + return "Без лимита" + return str(self.max_folders) + + @property + def photos_limit_label(self): + if self.max_photos == 0: + return "Без лимита" + return str(self.max_photos) + + +class AdBanner(db.Model): + __tablename__ = "ad_banners" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(120), nullable=False) + image_url = db.Column(db.String(500), nullable=False) + link_url = db.Column(db.String(500), nullable=True) + alt_text = db.Column(db.String(200), nullable=True) + position = db.Column(db.String(30), nullable=False, default="main", index=True) + is_active = db.Column(db.Boolean, nullable=False, default=True) + sort_order = db.Column(db.Integer, nullable=False, default=0) + created_at = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + POSITIONS = { + "main": "Главная (под hero)", + "cabinet": "Личный кабинет", + "sidebar": "Боковая колонка", + "footer": "Подвал", + } + + @property + def position_label(self): + return self.POSITIONS.get(self.position, self.position) + class Folder(db.Model): __tablename__ = "folders" diff --git a/app/quota_utils.py b/app/quota_utils.py index fdda8b4..bb58f17 100644 --- a/app/quota_utils.py +++ b/app/quota_utils.py @@ -1,7 +1,7 @@ from sqlalchemy import func from app import db -from app.models import Photo, User, UserGroup +from app.models import Folder, Photo, User, UserGroup def get_default_group(): @@ -21,12 +21,52 @@ def get_user_storage_used(user_id): return int(result or 0) +def get_user_photo_count(user_id): + return Photo.query.filter_by(user_id=user_id).count() + + +def get_user_folder_count(user_id): + return Folder.query.filter_by(owner_id=user_id).count() + + def get_group_quota_bytes(group): if not group or group.disk_quota_mb == 0: return None return group.disk_quota_mb * 1024 * 1024 +def _limit_reached(current, limit): + return limit > 0 and current >= limit + + +def check_folder_limit(user): + group = get_user_group(user) + if not group or group.max_folders == 0: + return True, "" + + count = get_user_folder_count(user.id) + if _limit_reached(count, group.max_folders): + return False, ( + f"Достигнут лимит папок группы «{group.name}»: " + f"{count} / {group.max_folders}" + ) + return True, "" + + +def check_photo_count_limit(user, additional_count=1): + group = get_user_group(user) + if not group or group.max_photos == 0: + return True, "" + + count = get_user_photo_count(user.id) + if count + additional_count > group.max_photos: + return False, ( + f"Достигнут лимит фото группы «{group.name}»: " + f"{count} / {group.max_photos}" + ) + return True, "" + + def check_upload_quota(user, new_file_size): group = get_user_group(user) quota_bytes = get_group_quota_bytes(group) @@ -35,9 +75,7 @@ def check_upload_quota(user, new_file_size): used = get_user_storage_used(user.id) if used + new_file_size > quota_bytes: - from app.models import Photo as _Photo - - used_human = _Photo(file_size=used).size_human if used else "0 Б" + used_human = Photo(file_size=used).size_human if used else "0 Б" quota_human = f"{group.disk_quota_mb} МБ" return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}" return True, "" @@ -46,7 +84,13 @@ def check_upload_quota(user, new_file_size): def quota_status(user): group = get_user_group(user) used = get_user_storage_used(user.id) + photo_count = get_user_photo_count(user.id) + folder_count = get_user_folder_count(user.id) quota_bytes = get_group_quota_bytes(group) + + photos_unlimited = not group or group.max_photos == 0 + folders_unlimited = not group or group.max_folders == 0 + if quota_bytes is None: return { "group": group, @@ -54,12 +98,40 @@ def quota_status(user): "quota_bytes": None, "percent": 0, "unlimited": True, + "photo_count": photo_count, + "photo_limit": group.max_photos if group else 0, + "photos_unlimited": photos_unlimited, + "photos_percent": 0, + "folder_count": folder_count, + "folder_limit": group.max_folders if group else 0, + "folders_unlimited": folders_unlimited, + "folders_percent": 0, } + percent = min(100, int(used / quota_bytes * 100)) if quota_bytes else 0 + photos_percent = ( + min(100, int(photo_count / group.max_photos * 100)) + if group and group.max_photos + else 0 + ) + folders_percent = ( + min(100, int(folder_count / group.max_folders * 100)) + if group and group.max_folders + else 0 + ) + return { "group": group, "used": used, "quota_bytes": quota_bytes, "percent": percent, "unlimited": False, + "photo_count": photo_count, + "photo_limit": group.max_photos if group else 0, + "photos_unlimited": photos_unlimited, + "photos_percent": photos_percent, + "folder_count": folder_count, + "folder_limit": group.max_folders if group else 0, + "folders_unlimited": folders_unlimited, + "folders_percent": folders_percent, } diff --git a/app/static/css/style.css b/app/static/css/style.css index 26351bd..7f6cd04 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -1045,3 +1045,118 @@ body { .settings-form .admin-panel { margin-bottom: 0; } + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; +} + +.quota-bar__limits { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 10px; + font-size: 0.8rem; + color: var(--text-muted); +} + +.quota-bar__track--sm { + height: 6px; + margin-top: 8px; +} + +.form-hint { + margin-top: 12px; + font-size: 0.85rem; + color: var(--text-muted); +} + +.form-hint--warn { + color: #f97316; +} + +.ad-banners { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px 0; +} + +.ad-banners--main, +.ad-banners--cabinet { + padding-top: 0; +} + +.ad-banners--footer { + padding-bottom: 0; +} + +.ad-banner { + width: min(100%, 728px); +} + +.ad-banner__link { + display: block; + border-radius: var(--radius-sm); + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; +} + +.ad-banner__link:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.ad-banner__img { + display: block; + width: 100%; + height: auto; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.banner-preview { + display: flex; + align-items: center; + gap: 12px; +} + +.banner-preview__img { + width: 120px; + height: 48px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border); +} + +.banner-edit-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.banner-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.form-checkbox--inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; +} + +.badge--muted { + background: rgba(255, 255, 255, 0.08); + color: var(--text-muted); +} + +.admin-table--groups td { + vertical-align: top; +} diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index d5c2ae6..5eac24a 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -2,6 +2,7 @@ Обзор Пользователи Группы + Баннеры Фото Версии Git Настройки diff --git a/app/templates/admin/banners.html b/app/templates/admin/banners.html new file mode 100644 index 0000000..e478169 --- /dev/null +++ b/app/templates/admin/banners.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} + +{% block title %}Рекламные баннеры — Админка{% endblock %} + +{% block content %} + + +
+
+ {% include "admin/_nav.html" %} + {% include "partials/alerts.html" %} + +
+

Добавить баннер

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ + + + + + + + + + + {% for banner in banners %} + + + + + + + {% else %} + + + + {% endfor %} + +
БаннерПозицияСтатусДействия
+ + {{ banner.position_label }} + {% if banner.is_active %} + активен + {% else %} + выключен + {% endif %} + + + +
Баннеров пока нет
+
+
+
+{% endblock %} diff --git a/app/templates/admin/groups.html b/app/templates/admin/groups.html index 43bc46c..8634285 100644 --- a/app/templates/admin/groups.html +++ b/app/templates/admin/groups.html @@ -7,7 +7,7 @@ @@ -23,20 +23,32 @@ -
- - +
+
+ + +
+
+ + +
+
+ + +
- +
+ + @@ -51,12 +63,16 @@ {% if group.is_default %}по умолчанию{% endif %} + +
Группа КвотаПапкиФото Пользователей Занято Действия {{ group.quota_label }}{{ item.folder_count }}{% if group.max_folders %} / {{ group.max_folders }}{% endif %}{{ item.photo_count }}{% if group.max_photos %} / {{ group.max_photos }}{% endif %} {{ group.user_count }} {{ format_size(item.storage_used) }}
- - + + + +
{% if not group.is_default %} diff --git a/app/templates/base.html b/app/templates/base.html index 52421f5..249d4c6 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -42,6 +42,10 @@ {% block content %}{% endblock %} + {% with banners=site_banners.get('footer', []), position='footer' %} + {% include "partials/banners.html" %} + {% endwith %} +