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 user group quota in MB (0 = unlimited)
DEFAULT_GROUP_QUOTA_MB=100 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) # Git deploy from admin panel (requires repo mount and docker socket)
ALLOW_GIT_DEPLOY=false ALLOW_GIT_DEPLOY=false
+39
View File
@@ -312,6 +312,8 @@ docker compose up -d --build
| `/cabinet/profile` | Настройки профиля, смена пароля | | `/cabinet/profile` | Настройки профиля, смена пароля |
| `/admin/` | Панель администратора (только admin) | | `/admin/` | Панель администратора (только admin) |
| `/admin/users` | Управление пользователями | | `/admin/users` | Управление пользователями |
| `/admin/groups` | Группы: квота диска, лимиты папок и фото |
| `/admin/banners` | Рекламные баннеры на сайте |
| `/admin/photos` | Все фото на сервере | | `/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` | Вход | | POST | `/auth/login` | Вход |
| GET | `/cabinet/` | Личный кабинет | | GET | `/cabinet/` | Личный кабинет |
| GET | `/admin/` | Админ-панель | | GET | `/admin/` | Админ-панель |
| GET | `/admin/banners` | Управление рекламными баннерами |
| POST | `/upload` | Загрузка фото (auth) | | POST | `/upload` | Загрузка фото (auth) |
| GET | `/uploads/<filename>` | Прямая ссылка на файл | | GET | `/uploads/<filename>` | Прямая ссылка на файл |
| GET | `/api/photos` | JSON-список всех фото | | GET | `/api/photos` | JSON-список всех фото |
+9
View File
@@ -59,8 +59,15 @@ def create_app():
register_cli(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(): with app.app_context():
from app.models import ( # noqa: F401 from app.models import ( # noqa: F401
AdBanner,
Folder, Folder,
FolderInvite, FolderInvite,
FolderMember, FolderMember,
@@ -75,6 +82,7 @@ def create_app():
from app.bootstrap import ( from app.bootstrap import (
create_first_admin, create_first_admin,
ensure_default_group, ensure_default_group,
ensure_group_limit_columns,
ensure_photo_storage_column, ensure_photo_storage_column,
ensure_schema, ensure_schema,
ensure_site_settings, ensure_site_settings,
@@ -83,6 +91,7 @@ def create_app():
ensure_schema() ensure_schema()
ensure_default_group(app) ensure_default_group(app)
ensure_group_limit_columns()
ensure_folder_schema() ensure_folder_schema()
ensure_site_settings(app) ensure_site_settings(app)
ensure_photo_storage_column() ensure_photo_storage_column()
+120 -4
View File
@@ -15,8 +15,8 @@ from app.deploy_utils import (
get_deploy_status, get_deploy_status,
is_deploy_enabled, is_deploy_enabled,
) )
from app.models import Photo, User, UserGroup from app.models import AdBanner, Photo, User, UserGroup
from app.quota_utils import get_user_storage_used 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.settings_service import get_settings, update_settings_from_form
from app.storage_service import delete_photo_file from app.storage_service import delete_photo_file
@@ -131,6 +131,12 @@ def groups():
if request.method == "POST": if request.method == "POST":
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
quota_mb = request.form.get("disk_quota_mb", type=int) or 100 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: if len(name) < 2:
flash("Название группы — минимум 2 символа", "error") flash("Название группы — минимум 2 символа", "error")
@@ -144,7 +150,13 @@ def groups():
slug = f"{base_slug}-{counter}" slug = f"{base_slug}-{counter}"
counter += 1 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.add(group)
db.session.commit() db.session.commit()
flash(f"Группа «{name}» создана", "success") flash(f"Группа «{name}» создана", "success")
@@ -154,7 +166,14 @@ def groups():
group_stats = [] group_stats = []
for group in all_groups: for group in all_groups:
used = sum(get_user_storage_used(u.id) for u in group.users) 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) 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) group = UserGroup.query.get_or_404(group_id)
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
quota_mb = request.form.get("disk_quota_mb", type=int) 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: if len(name) < 2:
flash("Название группы — минимум 2 символа", "error") flash("Название группы — минимум 2 символа", "error")
@@ -177,6 +198,10 @@ def edit_group(group_id):
group.name = name group.name = name
if quota_mb is not None: if quota_mb is not None:
group.disk_quota_mb = max(0, quota_mb) 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() db.session.commit()
flash(f"Группа «{group.name}» обновлена", "success") flash(f"Группа «{group.name}» обновлена", "success")
return redirect(url_for("admin.groups")) return redirect(url_for("admin.groups"))
@@ -202,6 +227,97 @@ def delete_group(group_id):
return redirect(url_for("admin.groups")) 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") @bp.route("/photos")
@admin_required @admin_required
def photos(): 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): def ensure_default_group(app):
default_quota = int(os.getenv("DEFAULT_GROUP_QUOTA_MB", "100")) 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() default_group = UserGroup.query.filter_by(is_default=True).first()
if not default_group: if not default_group:
@@ -41,16 +43,41 @@ def ensure_default_group(app):
name="Пользователи", name="Пользователи",
slug="users", slug="users",
disk_quota_mb=default_quota, disk_quota_mb=default_quota,
max_folders=default_max_folders,
max_photos=default_max_photos,
is_default=True, is_default=True,
) )
db.session.add(default_group) db.session.add(default_group)
db.session.commit() 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}) User.query.filter(User.group_id.is_(None)).update({"group_id": default_group.id})
db.session.commit() 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): def ensure_site_settings(app):
from app.models import SiteSettings from app.models import SiteSettings
+14 -1
View File
@@ -22,6 +22,7 @@ from app.folder_utils import (
unlock_folder, unlock_folder,
) )
from app.models import Folder, FolderInvite, FolderMember, Photo, User 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.settings_service import get_settings
from app.storage_service import delete_photo_file from app.storage_service import delete_photo_file
@@ -31,6 +32,8 @@ bp = Blueprint("folders", __name__)
@bp.route("/cabinet/folders") @bp.route("/cabinet/folders")
@login_required @login_required
def list_folders(): def list_folders():
from app.quota_utils import quota_status
process_pending_invites(current_user) process_pending_invites(current_user)
owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all() owned = Folder.query.filter_by(owner_id=current_user.id).order_by(Folder.created_at.desc()).all()
shared = ( shared = (
@@ -42,7 +45,12 @@ def list_folders():
.order_by(Folder.created_at.desc()) .order_by(Folder.created_at.desc())
.all() .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"]) @bp.route("/cabinet/folders/create", methods=["POST"])
@@ -56,6 +64,11 @@ def create_folder():
flash("Название папки — минимум 2 символа", "error") flash("Название папки — минимум 2 символа", "error")
return redirect(url_for("folders.list_folders")) 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) folder = Folder(name=name, owner_id=current_user.id, is_private=is_private)
if access_password: if access_password:
if len(access_password) < 4: 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) name = db.Column(db.String(80), unique=True, nullable=False)
slug = db.Column(db.String(80), unique=True, nullable=False, index=True) slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
disk_quota_mb = db.Column(db.Integer, nullable=False, default=100) 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) is_default = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column( created_at = db.Column(
db.DateTime, db.DateTime,
@@ -150,6 +152,47 @@ class UserGroup(db.Model):
return "Без лимита" return "Без лимита"
return f"{self.disk_quota_mb} МБ" 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): class Folder(db.Model):
__tablename__ = "folders" __tablename__ = "folders"
+76 -4
View File
@@ -1,7 +1,7 @@
from sqlalchemy import func from sqlalchemy import func
from app import db from app import db
from app.models import Photo, User, UserGroup from app.models import Folder, Photo, User, UserGroup
def get_default_group(): def get_default_group():
@@ -21,12 +21,52 @@ def get_user_storage_used(user_id):
return int(result or 0) 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): def get_group_quota_bytes(group):
if not group or group.disk_quota_mb == 0: if not group or group.disk_quota_mb == 0:
return None return None
return group.disk_quota_mb * 1024 * 1024 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): def check_upload_quota(user, new_file_size):
group = get_user_group(user) group = get_user_group(user)
quota_bytes = get_group_quota_bytes(group) 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) used = get_user_storage_used(user.id)
if used + new_file_size > quota_bytes: 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} МБ" quota_human = f"{group.disk_quota_mb} МБ"
return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}" return False, f"Превышена квота группы «{group.name}»: {used_human} / {quota_human}"
return True, "" return True, ""
@@ -46,7 +84,13 @@ def check_upload_quota(user, new_file_size):
def quota_status(user): def quota_status(user):
group = get_user_group(user) group = get_user_group(user)
used = get_user_storage_used(user.id) 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) 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: if quota_bytes is None:
return { return {
"group": group, "group": group,
@@ -54,12 +98,40 @@ def quota_status(user):
"quota_bytes": None, "quota_bytes": None,
"percent": 0, "percent": 0,
"unlimited": True, "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 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 { return {
"group": group, "group": group,
"used": used, "used": used,
"quota_bytes": quota_bytes, "quota_bytes": quota_bytes,
"percent": percent, "percent": percent,
"unlimited": False, "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 { .settings-form .admin-panel {
margin-bottom: 0; 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.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.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.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.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.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> <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"> <section class="page-header page-header--admin">
<div class="container"> <div class="container">
<h1 class="page-header__title">Группы пользователей</h1> <h1 class="page-header__title">Группы пользователей</h1>
<p class="page-header__subtitle">Квоты дискового пространства и назначение пользователей</p> <p class="page-header__subtitle">Квоты диска, лимиты папок и фото для каждой группы</p>
</div> </div>
</section> </section>
@@ -23,20 +23,32 @@
<label for="name">Название</label> <label for="name">Название</label>
<input type="text" id="name" name="name" required minlength="2" placeholder="VIP"> <input type="text" id="name" name="name" required minlength="2" placeholder="VIP">
</div> </div>
<div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label> <label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500"> <input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
</div> </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> <button type="submit" class="btn btn--primary">Создать группу</button>
</form> </form>
</div> </div>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table admin-table--groups">
<thead> <thead>
<tr> <tr>
<th>Группа</th> <th>Группа</th>
<th>Квота</th> <th>Квота</th>
<th>Папки</th>
<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 %} {% if group.is_default %}<span class="badge badge--success">по умолчанию</span>{% endif %}
</td> </td>
<td>{{ group.quota_label }}</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>{{ group.user_count }}</td>
<td>{{ format_size(item.storage_used) }}</td> <td>{{ format_size(item.storage_used) }}</td>
<td> <td>
<form method="post" action="{{ url_for('admin.edit_group', group_id=group.id) }}" class="group-edit-form"> <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="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"> <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> <button type="submit" class="btn btn--ghost btn--sm">Сохранить</button>
</form> </form>
{% if not group.is_default %} {% if not group.is_default %}
+4
View File
@@ -42,6 +42,10 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% with banners=site_banners.get('footer', []), position='footer' %}
{% include "partials/banners.html" %}
{% endwith %}
<footer class="footer"> <footer class="footer">
<div class="container footer__inner"> <div class="container footer__inner">
<p>PhotoHost — Python + PostgreSQL + Docker</p> <p>PhotoHost — Python + PostgreSQL + Docker</p>
+11
View File
@@ -16,6 +16,9 @@
<div class="container"> <div class="container">
<div class="admin-panel folder-create"> <div class="admin-panel folder-create">
<h2 class="admin-panel__title">Создать папку</h2> <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"> <form method="post" action="{{ url_for('folders.create_folder') }}" class="auth-form folder-create__form">
<div class="form-group"> <div class="form-group">
<label for="name">Название</label> <label for="name">Название</label>
@@ -31,6 +34,14 @@
</label> </label>
<button type="submit" class="btn btn--primary">Создать папку</button> <button type="submit" class="btn btn--primary">Создать папку</button>
</form> </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> </div>
<h2 class="section-title">Мои папки</h2> <h2 class="section-title">Мои папки</h2>
+34
View File
@@ -17,6 +17,10 @@
{% include "partials/alerts.html" %} {% include "partials/alerts.html" %}
{% with banners=site_banners.get('cabinet', []), position='cabinet' %}
{% include "partials/banners.html" %}
{% endwith %}
<section class="stats-bar"> <section class="stats-bar">
<div class="container stats"> <div class="container stats">
<div class="stat-card"> <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 class="quota-bar__fill {% if quota.percent > 90 %}quota-bar__fill--warn{% endif %}" style="width: {{ quota.percent }}%"></div>
</div> </div>
{% endif %} {% 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>
</div> </div>
{% endif %} {% endif %}
+5 -1
View File
@@ -38,7 +38,11 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
</section> </section>
{% with banners=site_banners.get('main', []), position='main' %}
{% include "partials/banners.html" %}
{% endwith %}
{% include "partials/alerts.html" %} {% 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 import db
from app.models import Photo 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.settings_service import get_settings
from app.storage_service import save_photo_file 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: if not valid_files:
return {"uploaded": 0, "errors": errors, "photos": []} 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) ok, quota_msg = check_upload_quota(user, total_size)
if not ok: if not ok:
return {"uploaded": 0, "errors": [quota_msg], "photos": []} return {"uploaded": 0, "errors": [quota_msg], "photos": []}