v1.4: group folder/photo limits and ad banners
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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-group">
|
||||
<label for="disk_quota_mb">Квота (МБ, 0 = без лимита)</label>
|
||||
<input type="number" id="disk_quota_mb" name="disk_quota_mb" min="0" value="500">
|
||||
<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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
|
||||
@@ -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.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": []}
|
||||
|
||||
Reference in New Issue
Block a user