v1.4: group folder/photo limits and ad banners

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-06 22:50:10 +03:00
parent 6a6704bc4b
commit 69715ecd06
19 changed files with 686 additions and 19 deletions
+2
View File
@@ -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
+39
View File
@@ -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/<filename>` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото |
+9
View File
@@ -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()
+120 -4
View File
@@ -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/<int:banner_id>/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/<int:banner_id>/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/<int:banner_id>/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():
+16
View File
@@ -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
+28 -1
View File
@@ -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
+14 -1
View File
@@ -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:
+43
View File
@@ -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"
+76 -4
View File
@@ -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,
}
+115
View File
@@ -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;
}
+1
View File
@@ -2,6 +2,7 @@
<a href="{{ url_for('admin.dashboard') }}" class="admin-nav__link {% if request.endpoint == 'admin.dashboard' %}admin-nav__link--active{% endif %}">Обзор</a>
<a href="{{ url_for('admin.users') }}" class="admin-nav__link {% if request.endpoint == 'admin.users' %}admin-nav__link--active{% endif %}">Пользователи</a>
<a href="{{ url_for('admin.groups') }}" class="admin-nav__link {% if request.endpoint in ['admin.groups', 'admin.edit_group', 'admin.delete_group'] %}admin-nav__link--active{% endif %}">Группы</a>
<a href="{{ url_for('admin.banners') }}" class="admin-nav__link {% if request.endpoint in ['admin.banners', 'admin.edit_banner', 'admin.delete_banner', 'admin.toggle_banner'] %}admin-nav__link--active{% endif %}">Баннеры</a>
<a href="{{ url_for('admin.photos') }}" class="admin-nav__link {% if request.endpoint == 'admin.photos' %}admin-nav__link--active{% endif %}">Фото</a>
<a href="{{ url_for('admin.deploy') }}" class="admin-nav__link {% if request.endpoint == 'admin.deploy' %}admin-nav__link--active{% endif %}">Версии Git</a>
<a href="{{ url_for('admin.settings') }}" class="admin-nav__link {% if request.endpoint == 'admin.settings' %}admin-nav__link--active{% endif %}">Настройки</a>
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Рекламные баннеры — Админка{% endblock %}
{% block content %}
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Рекламные баннеры</h1>
<p class="page-header__subtitle">Баннеры на главной, в кабинете и в подвале сайта</p>
</div>
</section>
<section class="admin-section">
<div class="container">
{% include "admin/_nav.html" %}
{% include "partials/alerts.html" %}
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Добавить баннер</h2>
<form method="post" class="auth-form folder-create__form">
<div class="form-group">
<label for="title">Название (для админки)</label>
<input type="text" id="title" name="title" required minlength="2" placeholder="Промо лето">
</div>
<div class="form-group">
<label for="image_url">URL изображения</label>
<input type="url" id="image_url" name="image_url" required placeholder="https://example.com/banner.jpg">
</div>
<div class="form-group">
<label for="link_url">Ссылка при клике (необязательно)</label>
<input type="url" id="link_url" name="link_url" placeholder="https://example.com">
</div>
<div class="form-group">
<label for="alt_text">Alt-текст</label>
<input type="text" id="alt_text" name="alt_text" placeholder="Описание баннера">
</div>
<div class="form-group">
<label for="position">Позиция</label>
<select id="position" name="position" class="form-select">
{% for key, label in positions.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="sort_order">Порядок (меньше — выше)</label>
<input type="number" id="sort_order" name="sort_order" value="0">
</div>
<label class="form-checkbox">
<input type="checkbox" name="is_active" checked>
<span>Активен</span>
</label>
<button type="submit" class="btn btn--primary">Добавить баннер</button>
</form>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Баннер</th>
<th>Позиция</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for banner in banners %}
<tr>
<td>
<div class="banner-preview">
<img src="{{ banner.image_url }}" alt="" class="banner-preview__img">
<div>
<strong>{{ banner.title }}</strong>
{% if banner.link_url %}<br><small>{{ banner.link_url }}</small>{% endif %}
</div>
</div>
</td>
<td>{{ banner.position_label }}</td>
<td>
{% if banner.is_active %}
<span class="badge badge--success">активен</span>
{% else %}
<span class="badge badge--muted">выключен</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.edit_banner', banner_id=banner.id) }}" class="banner-edit-form">
<input type="text" name="title" value="{{ banner.title }}" required minlength="2" class="form-inline-input">
<input type="url" name="image_url" value="{{ banner.image_url }}" required class="form-inline-input">
<input type="url" name="link_url" value="{{ banner.link_url or '' }}" placeholder="Ссылка" class="form-inline-input">
<select name="position" class="form-select form-select--sm">
{% for key, label in positions.items() %}
<option value="{{ key }}" {% if banner.position == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<input type="number" name="sort_order" value="{{ banner.sort_order }}" class="form-inline-input form-inline-input--sm">
<label class="form-checkbox form-checkbox--inline">
<input type="checkbox" name="is_active" {% if banner.is_active %}checked{% endif %}>
<span>Активен</span>
</label>
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
</form>
<div class="banner-actions">
<form method="post" action="{{ url_for('admin.toggle_banner', banner_id=banner.id) }}">
<button type="submit" class="btn btn--ghost btn--sm">
{% if banner.is_active %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('admin.delete_banner', banner_id=banner.id) }}" onsubmit="return confirm('Удалить баннер?');">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">Баннеров пока нет</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+20 -4
View File
@@ -7,7 +7,7 @@
<section class="page-header page-header--admin">
<div class="container">
<h1 class="page-header__title">Группы пользователей</h1>
<p class="page-header__subtitle">Квоты дискового пространства и назначение пользователей</p>
<p class="page-header__subtitle">Квоты диска, лимиты папок и фото для каждой группы</p>
</div>
</section>
@@ -23,20 +23,32 @@
<label for="name">Название</label>
<input type="text" id="name" name="name" required minlength="2" placeholder="VIP">
</div>
<div class="form-row">
<div class="form-group">
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
</div>
<div class="form-group">
<label for="max_folders">Макс. папок (0 = без лимита)</label>
<input type="number" id="max_folders" name="max_folders" min="0" value="20">
</div>
<div class="form-group">
<label for="max_photos">Макс. фото (0 = без лимита)</label>
<input type="number" id="max_photos" name="max_photos" min="0" value="1000">
</div>
</div>
<button type="submit" class="btn btn--primary">Создать группу</button>
</form>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<table class="admin-table admin-table--groups">
<thead>
<tr>
<th>Группа</th>
<th>Квота</th>
<th>Папки</th>
<th>Фото</th>
<th>Пользователей</th>
<th>Занято</th>
<th>Действия</th>
@@ -51,12 +63,16 @@
{% if group.is_default %}<span class="badge badge--success">по умолчанию</span>{% endif %}
</td>
<td>{{ group.quota_label }}</td>
<td>{{ item.folder_count }}{% if group.max_folders %} / {{ group.max_folders }}{% endif %}</td>
<td>{{ item.photo_count }}{% if group.max_photos %} / {{ group.max_photos }}{% endif %}</td>
<td>{{ group.user_count }}</td>
<td>{{ format_size(item.storage_used) }}</td>
<td>
<form method="post" action="{{ url_for('admin.edit_group', group_id=group.id) }}" class="group-edit-form">
<input type="text" name="name" value="{{ group.name }}" required minlength="2" class="form-inline-input">
<input type="number" name="disk_quota_mb" value="{{ group.disk_quota_mb }}" min="0" class="form-inline-input form-inline-input--sm">
<input type="text" name="name" value="{{ group.name }}" required minlength="2" class="form-inline-input" title="Название">
<input type="number" name="disk_quota_mb" value="{{ group.disk_quota_mb }}" min="0" class="form-inline-input form-inline-input--sm" title="Квота МБ">
<input type="number" name="max_folders" value="{{ group.max_folders }}" min="0" class="form-inline-input form-inline-input--sm" title="Макс. папок">
<input type="number" name="max_photos" value="{{ group.max_photos }}" min="0" class="form-inline-input form-inline-input--sm" title="Макс. фото">
<button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
</form>
{% if not group.is_default %}
+4
View File
@@ -42,6 +42,10 @@
{% block content %}{% endblock %}
</main>
{% with banners=site_banners.get('footer', []), position='footer' %}
{% include "partials/banners.html" %}
{% endwith %}
<footer class="footer">
<div class="container footer__inner">
<p>PhotoHost — Python + PostgreSQL + Docker</p>
+11
View File
@@ -16,6 +16,9 @@
<div class="container">
<div class="admin-panel folder-create">
<h2 class="admin-panel__title">Создать папку</h2>
{% if quota and not quota.folders_unlimited and quota.folder_count >= quota.folder_limit %}
<p class="form-hint form-hint--warn">Достигнут лимит папок: {{ quota.folder_count }} / {{ quota.folder_limit }}</p>
{% else %}
<form method="post" action="{{ url_for('folders.create_folder') }}" class="auth-form folder-create__form">
<div class="form-group">
<label for="name">Название</label>
@@ -31,6 +34,14 @@
</label>
<button type="submit" class="btn btn--primary">Создать папку</button>
</form>
{% endif %}
{% if quota %}
<p class="form-hint">
Лимит группы «{{ quota.group.name if quota.group else 'Пользователи' }}»:
папки {{ quota.folder_count }}{% if not quota.folders_unlimited %} / {{ quota.folder_limit }}{% else %} / без лимита{% endif %},
фото {{ quota.photo_count }}{% if not quota.photos_unlimited %} / {{ quota.photo_limit }}{% else %} / без лимита{% endif %}
</p>
{% endif %}
</div>
<h2 class="section-title">Мои папки</h2>
+34
View File
@@ -17,6 +17,10 @@
{% include "partials/alerts.html" %}
{% with banners=site_banners.get('cabinet', []), position='cabinet' %}
{% include "partials/banners.html" %}
{% endwith %}
<section class="stats-bar">
<div class="container stats">
<div class="stat-card">
@@ -51,6 +55,36 @@
<div class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
</div>
{% endif %}
<div class="quota-bar__limits">
<span>
Папки:
{{ quota.folder_count }}
{% if not quota.folders_unlimited %}
/ {{ quota.folder_limit }}
{% else %}
/ без лимита
{% endif %}
</span>
<span>
Фото:
{{ quota.photo_count }}
{% if not quota.photos_unlimited %}
/ {{ quota.photo_limit }}
{% else %}
/ без лимита
{% endif %}
</span>
</div>
{% if not quota.folders_unlimited %}
<div class="quota-bar__track quota-bar__track--sm">
<div class="quota-bar__fill {% if quota.folders_percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.folders_percent }}%"></div>
</div>
{% endif %}
{% if not quota.photos_unlimited %}
<div class="quota-bar__track quota-bar__track--sm">
<div class="quota-bar__fill {% if quota.photos_percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.photos_percent }}%"></div>
</div>
{% endif %}
</div>
</div>
{% endif %}
+5 -1
View File
@@ -38,7 +38,11 @@
</div>
{% endif %}
</div>
</section>
</section>
{% with banners=site_banners.get('main', []), position='main' %}
{% include "partials/banners.html" %}
{% endwith %}
{% include "partials/alerts.html" %}
+15
View File
@@ -0,0 +1,15 @@
{% if banners %}
<div class="ad-banners ad-banners--{{ position|default('default') }}">
{% for banner in banners %}
<div class="ad-banner">
{% if banner.link_url %}
<a href="{{ banner.link_url }}" class="ad-banner__link" target="_blank" rel="noopener sponsored">
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
</a>
{% else %}
<img src="{{ banner.image_url }}" alt="{{ banner.alt_text or banner.title }}" class="ad-banner__img" loading="lazy">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
+5 -1
View File
@@ -6,7 +6,7 @@ from werkzeug.utils import secure_filename
from app import db
from app.models import Photo
from app.quota_utils import check_upload_quota
from app.quota_utils import check_photo_count_limit, check_upload_quota
from app.settings_service import get_settings
from app.storage_service import save_photo_file
@@ -56,6 +56,10 @@ def process_uploads(request_files, user, folder, allowed_extensions):
if not valid_files:
return {"uploaded": 0, "errors": errors, "photos": []}
ok, photo_limit_msg = check_photo_count_limit(user, len(valid_files))
if not ok:
return {"uploaded": 0, "errors": [photo_limit_msg], "photos": []}
ok, quota_msg = check_upload_quota(user, total_size)
if not ok:
return {"uploaded": 0, "errors": [quota_msg], "photos": []}