v1.4: group folder/photo limits and ad banners
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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-список всех фото |
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
</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" %}
|
||||||
|
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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": []}
|
||||||
|
|||||||
Reference in New Issue
Block a user