rough new admin panel, more changes pending

This commit is contained in:
2025-06-28 04:45:36 -05:00
parent adbb3250ad
commit 72e060d783
33 changed files with 1550 additions and 24 deletions

View File

@ -5,13 +5,16 @@ import json
import glob
import importlib
import importlib.util
import time
from flask import Flask
from flask import Flask,request
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv
from datetime import datetime
# Load environment variables from .env or system
load_dotenv()
@ -113,7 +116,36 @@ def create_app():
def inject_current_year():
from datetime import datetime
return {'current_year': datetime.now().year}
@app.context_processor
def inject_now():
return {'utcnow': datetime.utcnow()}
@app.before_request
def start_timer():
request._start_time = time.time()
@app.after_request
def log_analytics(response):
# import here to avoid circular at moduleload time
from plugins.admin.models import AnalyticsEvent
try:
duration = time.time() - getattr(request, '_start_time', time.time())
ev = AnalyticsEvent(
method=request.method,
path=request.path,
status_code=response.status_code,
response_time=duration,
user_agent=request.headers.get('User-Agent'),
referer=request.headers.get('Referer'),
accept_language=request.headers.get('Accept-Language'),
)
db.session.add(ev)
db.session.commit()
except Exception:
db.session.rollback()
return response
app.jinja_env.globals['generate_image_url'] = generate_image_url
return app

Binary file not shown.

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 01876f89899b
Revises: a69f613f9cd5
Create Date: 2025-06-28 08:15:57.708963
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '01876f89899b'
down_revision = 'a69f613f9cd5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 03650b9a0f3a
Revises: 85b7ca21ec19
Create Date: 2025-06-28 07:57:56.370633
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03650b9a0f3a'
down_revision = '85b7ca21ec19'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 0514fb24a61e
Revises: fe0ebdec3255
Create Date: 2025-06-28 09:25:58.833912
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0514fb24a61e'
down_revision = 'fe0ebdec3255'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 08ebb5577232
Revises: 0efc1a18285f
Create Date: 2025-06-28 09:34:14.207419
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '08ebb5577232'
down_revision = '0efc1a18285f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 0efc1a18285f
Revises: 886aa234b3b7
Create Date: 2025-06-28 09:30:43.185721
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0efc1a18285f'
down_revision = '886aa234b3b7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 30376c514135
Revises: 01876f89899b
Create Date: 2025-06-28 08:20:23.577743
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30376c514135'
down_revision = '01876f89899b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 390c977fe679
Revises: c23c31ae3a1d
Create Date: 2025-06-28 08:34:18.914877
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '390c977fe679'
down_revision = 'c23c31ae3a1d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 4082065b932b
Revises: 08ebb5577232
Create Date: 2025-06-28 09:41:11.323777
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4082065b932b'
down_revision = '08ebb5577232'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 51640ecd70ee
Revises: 390c977fe679
Create Date: 2025-06-28 08:35:37.016653
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '51640ecd70ee'
down_revision = '390c977fe679'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 807ca973d0cf
Revises: 51640ecd70ee
Create Date: 2025-06-28 08:46:56.744709
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '807ca973d0cf'
down_revision = '51640ecd70ee'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 886aa234b3b7
Revises: 0514fb24a61e
Create Date: 2025-06-28 09:27:59.962665
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '886aa234b3b7'
down_revision = '0514fb24a61e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: 892da654697c
Revises: f7f41136c073
Create Date: 2025-06-28 08:56:06.592485
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '892da654697c'
down_revision = 'f7f41136c073'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: a69f613f9cd5
Revises: a87d4c1df4e5
Create Date: 2025-06-28 08:13:00.288626
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a69f613f9cd5'
down_revision = 'a87d4c1df4e5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,39 @@
"""auto-migrate
Revision ID: a79453aefa45
Revises: 892da654697c
Create Date: 2025-06-28 09:18:37.918669
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a79453aefa45'
down_revision = '892da654697c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analytics_event',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('method', sa.String(length=10), nullable=False),
sa.Column('path', sa.String(length=200), nullable=False),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.Column('response_time', sa.Float(), nullable=False),
sa.Column('user_agent', sa.String(length=200), nullable=True),
sa.Column('referer', sa.String(length=200), nullable=True),
sa.Column('accept_language', sa.String(length=200), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('analytics_event')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: a87d4c1df4e5
Revises: 03650b9a0f3a
Create Date: 2025-06-28 08:04:13.547254
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a87d4c1df4e5'
down_revision = '03650b9a0f3a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: b56cd5e57987
Revises: a79453aefa45
Create Date: 2025-06-28 09:20:55.842491
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b56cd5e57987'
down_revision = 'a79453aefa45'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,36 @@
"""auto-migrate
Revision ID: c23c31ae3a1d
Revises: 30376c514135
Create Date: 2025-06-28 08:31:46.351949
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c23c31ae3a1d'
down_revision = '30376c514135'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_deleted', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('is_banned', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('suspended_until', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('suspended_until')
batch_op.drop_column('is_banned')
batch_op.drop_column('is_deleted')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: f7f41136c073
Revises: 807ca973d0cf
Create Date: 2025-06-28 08:50:28.814054
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f7f41136c073'
down_revision = '807ca973d0cf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: f81a9a44a7fb
Revises: b56cd5e57987
Create Date: 2025-06-28 09:22:10.689435
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f81a9a44a7fb'
down_revision = 'b56cd5e57987'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""auto-migrate
Revision ID: fe0ebdec3255
Revises: f81a9a44a7fb
Create Date: 2025-06-28 09:23:36.801994
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe0ebdec3255'
down_revision = 'f81a9a44a7fb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -2,5 +2,5 @@ import click
from flask import Flask
def register_cli(app: Flask):
# CLI entry-point for admin
# No CLI commands yet for admin plugin
pass

31
plugins/admin/forms.py Normal file
View File

@ -0,0 +1,31 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SelectField, BooleanField, IntegerField, SubmitField
from wtforms.validators import DataRequired, Optional, ValidationError, NumberRange
import re
def email_check(form, field):
pattern = re.compile(r'^[^@]+@[^@]+\.[^@]+$')
if not field.data or not pattern.match(field.data):
raise ValidationError('Invalid email address.')
class UserForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), email_check])
password = PasswordField('Password', validators=[Optional()])
role = SelectField('Role',
choices=[('user','User'), ('admin','Admin')],
validators=[DataRequired()])
is_verified = BooleanField('Verified')
excluded_from_analytics = BooleanField('Exclude from Analytics')
# new ban/suspend fields
ban_type = SelectField('Account Status',
choices=[
('active','Active'),
('temp','Temporarily Suspended'),
('perm','Permanently Banned')
],
validators=[DataRequired()])
suspend_days = IntegerField('Suspend days',
default=7,
validators=[Optional(), NumberRange(min=1, max=365)])
submit = SubmitField('Save')

17
plugins/admin/models.py Normal file
View File

@ -0,0 +1,17 @@
# plugins/admin/models.py
from app import db
from datetime import datetime
class AnalyticsEvent(db.Model):
__tablename__ = 'analytics_event'
id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
method = db.Column(db.String(10), nullable=False)
path = db.Column(db.String(200), nullable=False)
status_code = db.Column(db.Integer, nullable=False)
response_time = db.Column(db.Float, nullable=False)
user_agent = db.Column(db.String(200))
referer = db.Column(db.String(200))
accept_language = db.Column(db.String(200))

View File

@ -1,6 +1,6 @@
{
"name": "admin",
"version": "1.0.0",
"description": "Administration dashboard and plugin manager",
"entry_point": null
}
"version": "0.1.1",
"description": "Admin panel plugin for Nature In Pots",
"entry_point": "register_cli"
}

View File

@ -1,11 +1,334 @@
from flask import Blueprint, render_template
# plugins/admin/routes.py
from flask import (
Blueprint, render_template, redirect, url_for,
flash, request, jsonify
)
from flask_login import login_required, current_user
from flask_wtf.csrf import generate_csrf
from sqlalchemy import func, desc
from datetime import datetime, timedelta
bp = Blueprint('admin', __name__, template_folder='templates')
from app import db
from plugins.auth.models import User
from plugins.growlog.models import GrowLog
from plugins.plant.models import Plant
from plugins.admin.models import AnalyticsEvent
from .forms import UserForm
@bp.route('/admin')
bp = Blueprint('admin', __name__, url_prefix='/admin', template_folder='templates')
@bp.route('/dashboard')
@login_required
def admin_dashboard():
def dashboard():
if current_user.role != 'admin':
return "Access denied", 403
return render_template('admin/admin_dashboard.html')
now = datetime.utcnow()
week_ago = now - timedelta(days=7)
month_ago= now - timedelta(days=30)
# ─── Overview metrics ────────────────────────────────────────────── #
# Users
total_users = User.query.count()
active_users = User.query.filter(
User.is_deleted == False,
User.is_banned == False,
(User.suspended_until == None) | (User.suspended_until <= now)
).count()
suspended_users = User.query.filter(User.suspended_until > now).count()
banned_users = User.query.filter_by(is_banned=True).count()
deleted_users = User.query.filter_by(is_deleted=True).count()
new_7d_users = User.query.filter(User.created_at >= week_ago).count()
# Growlogs
total_logs = GrowLog.query.count()
logs_7d = GrowLog.query.filter(GrowLog.created_at >= week_ago).count()
# Signups last 30 days
signup_dates = [(month_ago + timedelta(days=i)).date() for i in range(31)]
signup_counts = [
User.query.filter(func.date(User.created_at)==d).count()
for d in signup_dates
]
chart_dates = [d.strftime('%Y-%m-%d') for d in signup_dates]
# Status distribution
role_counts = {
'User': User.query.filter_by(role='user').count(),
'Admin': User.query.filter_by(role='admin').count()
}
# ─── Analytics aggregates ─────────────────────────────────────────── #
ev_q = AnalyticsEvent.query.filter(AnalyticsEvent.timestamp >= week_ago)
total_ev = ev_q.count() or 1
error_ev = ev_q.filter(AnalyticsEvent.status_code >= 500).count()
error_pct = round(error_ev / total_ev * 100, 1)
top_routes = [
{'path': path, 'count': cnt}
for path, cnt in db.session.query(
AnalyticsEvent.path,
func.count(AnalyticsEvent.id)
)
.filter(AnalyticsEvent.timestamp >= week_ago)
.group_by(AnalyticsEvent.path)
.order_by(desc(func.count(AnalyticsEvent.id)))
.limit(10)
.all()
]
# Browser share
browsers = {}
ua_counts = (
db.session.query(
func.substr(AnalyticsEvent.user_agent, 1, 200).label('ua'),
func.count(AnalyticsEvent.id).label('cnt')
)
.filter(AnalyticsEvent.user_agent.isnot(None))
.filter(AnalyticsEvent.timestamp >= week_ago)
.group_by(func.substr(AnalyticsEvent.user_agent, 1, 200))
.all()
)
for ua, cnt in ua_counts:
b = 'Other'
if 'Chrome' in ua: b = 'Chrome'
elif 'Firefox' in ua: b = 'Firefox'
elif 'Safari' in ua and 'Chrome' not in ua: b = 'Safari'
elif 'Edge' in ua: b = 'Edge'
browsers[b] = browsers.get(b, 0) + cnt
referrers = dict(
db.session.query(
AnalyticsEvent.referer, func.count(AnalyticsEvent.id)
)
.filter(AnalyticsEvent.referer.isnot(None))
.filter(AnalyticsEvent.timestamp >= week_ago)
.group_by(AnalyticsEvent.referer)
.order_by(desc(func.count(AnalyticsEvent.id)))
.limit(5)
.all()
)
# ─── Stats metrics ────────────────────────────────────────────────── #
# Plant totals
total_plants = Plant.query.count()
active_plants = Plant.query.filter_by(is_active=True).count()
# Top 5 popular plant types
popular_plants = db.session.query(
Plant.plant_type,
func.count(Plant.id).label('count')
).filter(Plant.is_active==True) \
.group_by(Plant.plant_type) \
.order_by(desc('count')) \
.limit(5) \
.all()
# ─── Render template ──────────────────────────────────────────────── #
return render_template(
'admin/dashboard.html',
# Overview
total_users=total_users,
active_users=active_users,
suspended_users=suspended_users,
banned_users=banned_users,
deleted_users=deleted_users,
new_7d_users=new_7d_users,
total_logs=total_logs,
logs_7d=logs_7d,
chart_dates=chart_dates,
signup_counts=signup_counts,
role_counts=role_counts,
# Analytics
error_pct=error_pct,
top_routes=top_routes,
browsers=browsers,
referrers=referrers,
# Stats
total_plants=total_plants,
active_plants=active_plants,
popular_plants=popular_plants,
)
@bp.route('/users')
@login_required
def list_users():
if current_user.role != 'admin':
return "Access denied", 403
# --- parse query parameters ---
page = request.args.get('page', 1, type=int)
show_deleted = request.args.get('show_deleted', '0') == '1'
q = request.args.get('q', '', type=str).strip()
# --- build base query ---
query = User.query
if not show_deleted:
query = query.filter_by(is_deleted=False)
if q:
like = f"%{q}%"
query = query.filter(User.email.ilike(like))
# --- paginate the results ---
pagination = query.order_by(User.created_at.desc()) \
.paginate(page=page, per_page=25, error_out=False)
users = pagination.items
# --- AJAX response (JSON) ---
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
token = generate_csrf()
users_data = []
for u in users:
if u.is_banned:
status = 'banned'
until = None
elif u.suspended_until and u.suspended_until > datetime.utcnow():
status = 'suspended'
until = u.suspended_until.strftime('%Y-%m-%d')
else:
status = 'active'
until = None
users_data.append({
'id': u.id,
'email': u.email,
'role': u.role,
'is_verified': u.is_verified,
'excluded_from_analytics': u.excluded_from_analytics,
'status': status,
'suspended_until': until,
'is_deleted': u.is_deleted,
'created_at': u.created_at.strftime('%Y-%m-%d')
})
return jsonify({
'users': users_data,
'csrf_token': token
})
# --- normal HTML response ---
return render_template(
'admin/users/list.html',
users=users,
pagination=pagination,
show_deleted=show_deleted,
q=q
)
@bp.route('/users/create', methods=['GET', 'POST'])
@login_required
def create_user():
if current_user.role != 'admin':
return "Access denied", 403
form = UserForm()
if form.validate_on_submit():
u = User(
email=form.email.data,
role=form.role.data,
is_verified=form.is_verified.data,
excluded_from_analytics=form.excluded_from_analytics.data
)
# ban/suspend logic here if you need it...
if form.password.data:
u.set_password(form.password.data)
db.session.add(u)
db.session.commit()
flash('User created.', 'success')
return redirect(url_for('admin.list_users'))
return render_template('admin/users/form.html', form=form, action='Create')
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_user(user_id):
if current_user.role != 'admin':
return "Access denied", 403
u = User.query.get_or_404(user_id)
form = UserForm(obj=u)
# Pre-populate on GET
if request.method == 'GET':
if u.is_banned:
form.ban_type.data = 'perm'
elif u.suspended_until and u.suspended_until > datetime.utcnow():
form.ban_type.data = 'temp'
days = (u.suspended_until - datetime.utcnow()).days
form.suspend_days.data = days if days > 0 else 1
else:
form.ban_type.data = 'active'
if form.validate_on_submit():
u.email = form.email.data
u.role = form.role.data
u.is_verified = form.is_verified.data
u.excluded_from_analytics = form.excluded_from_analytics.data
# Ban/Suspend logic
if form.ban_type.data == 'perm':
u.is_banned = True
u.suspended_until = None
elif form.ban_type.data == 'temp':
u.is_banned = False
days = form.suspend_days.data or 7
u.suspended_until = datetime.utcnow() + timedelta(days=days)
else:
u.is_banned = False
u.suspended_until = None
if form.password.data:
u.set_password(form.password.data)
db.session.commit()
flash('User updated.', 'success')
return redirect(url_for('admin.list_users',
page=request.args.get('page'),
show_deleted=request.args.get('show_deleted'),
q=request.args.get('q')))
return render_template(
'admin/users/form.html',
form=form,
action='Edit'
)
@bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_user(user_id):
if current_user.role != 'admin':
return "Access denied", 403
u = User.query.get_or_404(user_id)
if u.id == current_user.id:
flash("Cannot delete yourself.", 'warning')
else:
u.is_deleted = True
db.session.commit()
flash('User deleted (soft).', 'success')
return redirect(url_for('admin.list_users',
page=request.args.get('page'),
show_deleted=request.args.get('show_deleted'),
q=request.args.get('q')))
@bp.route('/users/<int:user_id>/undelete', methods=['POST'])
@login_required
def undelete_user(user_id):
if current_user.role != 'admin':
return "Access denied", 403
u = User.query.get_or_404(user_id)
u.is_deleted = False
db.session.commit()
flash('User restored.', 'success')
return redirect(url_for('admin.list_users',
page=request.args.get('page'),
show_deleted=request.args.get('show_deleted'),
q=request.args.get('q')))

View File

@ -1,11 +0,0 @@
{% extends 'core_ui/base.html' %}
{% block title %}Admin Dashboard | Nature In Pots{% endblock %}
{% block content %}
<h1 class="mb-4 text-danger">Admin Dashboard</h1>
<p class="lead">Manage submissions, users, and plugin controls here.</p>
<ul>
<li><a href="#">View Unapproved Submissions</a></li>
<li><a href="#">Manage Users</a></li>
<li><a href="#">Export Data</a></li>
</ul>
{% endblock %}

View File

@ -0,0 +1,244 @@
{# plugins/admin/templates/admin/dashboard.html #}
{% extends 'core_ui/base.html' %}
{% block title %}Admin Dashboard Nature In Pots{% endblock %}
{% block styles %}
<style>
.dashboard-chart { height: 250px; }
</style>
{% endblock %}
{% block content %}
<h1 class="mb-4">Admin Dashboard</h1>
{# ─── Tab Nav ─────────────────────────────────────────────────────── #}
<ul class="nav nav-tabs mb-4" id="adminTabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active"
id="overview-tab"
data-bs-toggle="tab"
data-bs-target="#overview"
type="button"
role="tab"
aria-controls="overview"
aria-selected="true">
Overview
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="stats-tab"
data-bs-toggle="tab"
data-bs-target="#stats"
type="button"
role="tab"
aria-controls="stats"
aria-selected="false">
Stats
</button>
</li>
</ul>
<div class="tab-content" id="adminTabsContent">
{# ─── Overview Pane ─────────────────────────────────────────────── #}
<div
class="tab-pane fade show active"
id="overview"
role="tabpanel"
aria-labelledby="overview-tab"
>
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-4 g-4 mb-5">
{% for label, value in [
('Total Users', total_users),
('Active Users', active_users),
('Suspended Users', suspended_users),
('Banned Users', banned_users),
('Deleted Users', deleted_users),
('New Sign-ups (7d)', new_7d_users),
('Total Logs', total_logs),
('Logs (7d)', logs_7d),
('Error Rate %', error_pct),
] %}
<div class="col">
<div class="card h-100 text-center">
<div class="card-body">
<h6 class="text-muted">{{ label }}</h6>
<p class="display-6 mb-0">{{ value }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="row g-4 mb-5">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Sign-ups (Last 30 days)</div>
<div class="card-body">
<canvas id="signupChart" class="dashboard-chart w-100"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">User Status Breakdown</div>
<div class="card-body">
<canvas id="statusChart" class="dashboard-chart w-100"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-5">
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">Top Routes (7d)</div>
<ul class="list-group list-group-flush">
{% for r in top_routes %}
<li class="list-group-item d-flex justify-content-between">
{{ r.path }}
<span class="badge bg-secondary">{{ r.count }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">Top Referrers (7d)</div>
<ul class="list-group list-group-flush">
{% for ref, cnt in referrers.items() %}
<li class="list-group-item d-flex justify-content-between">
{{ ref }}
<span class="badge bg-secondary">{{ cnt }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">Browsers (7d)</div>
<div class="card-body">
<canvas id="browserChart" class="dashboard-chart w-100"></canvas>
</div>
</div>
</div>
</div>
</div>
{# ─── Stats Pane ────────────────────────────────────────────────── #}
<div
class="tab-pane fade"
id="stats"
role="tabpanel"
aria-labelledby="stats-tab"
>
<div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
<div class="col">
<div class="card h-100 text-center">
<div class="card-body">
<h6 class="text-muted">Total Plants</h6>
<p class="display-6 mb-0">{{ total_plants }}</p>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 text-center">
<div class="card-body">
<h6 class="text-muted">Active Plants</h6>
<p class="display-6 mb-0">{{ active_plants }}</p>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 text-center">
<div class="card-body">
<h6 class="text-muted">Total Logs</h6>
<p class="display-6 mb-0">{{ total_logs }}</p>
</div>
</div>
</div>
<div class="col">
<div class="card h-100 text-center">
<div class="card-body">
<h6 class="text-muted">Logs (7d)</h6>
<p class="display-6 mb-0">{{ logs_7d }}</p>
</div>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-header">Top 5 Plant Types</div>
<ul class="list-group list-group-flush">
{% for ptype, cnt in popular_plants %}
<li class="list-group-item d-flex justify-content-between">
{{ ptype }}
<span class="badge bg-secondary">{{ cnt }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const chartOpts = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } }
};
// Sign-ups
new Chart(
document.getElementById('signupChart'),
{
type: 'line',
data: {
labels: {{ chart_dates|tojson }},
datasets: [{ label: 'Sign-ups', data: {{ signup_counts|tojson }}, fill: false }]
},
options: chartOpts
}
);
// Status breakdown
new Chart(
document.getElementById('statusChart'),
{
type: 'pie',
data: {
labels: ['Active','Suspended','Banned','Deleted'],
datasets: [{
data: [
{{ active_users }},
{{ suspended_users }},
{{ banned_users }},
{{ deleted_users }}
]
}]
},
options: chartOpts
}
);
// Browsers
new Chart(
document.getElementById('browserChart'),
{
type: 'pie',
data: {
labels: {{ browsers.keys()|list|tojson }},
datasets: [{ data: {{ browsers.values()|list|tojson }} }]
},
options: chartOpts
}
);
</script>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends 'core_ui/base.html' %}
{% block title %}{{ action }} User Admin Nature In Pots{% endblock %}
{% block content %}
<h1>{{ action }} User</h1>
<form method="post">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% if action == 'Edit' %}
<small class="form-text text-muted">
Leave blank to keep current password.
</small>
{% endif %}
</div>
<div class="mb-3">
{{ form.role.label(class="form-label") }}
{{ form.role(class="form-select") }}
</div>
<div class="form-check mb-2">
{{ form.is_verified(class="form-check-input") }}
{{ form.is_verified.label(class="form-check-label") }}
</div>
<div class="form-check mb-3">
{{ form.excluded_from_analytics(class="form-check-input") }}
{{ form.excluded_from_analytics.label(class="form-check-label") }}
</div>
<hr>
<div class="mb-3">
{{ form.ban_type.label(class="form-label") }}
{{ form.ban_type(class="form-select") }}
</div>
<div class="mb-3">
{{ form.suspend_days.label(class="form-label") }}
{{ form.suspend_days(class="form-control") }}
<small class="form-text text-muted">
Only used if “Temporarily Suspended” is selected (default 7 days)
</small>
</div>
<button type="submit" class="btn btn-success">{{ action }}</button>
<a href="{{ url_for('admin.list_users') }}" class="btn btn-secondary">Cancel</a>
</form>
{% endblock %}

View File

@ -0,0 +1,244 @@
{% extends 'core_ui/base.html' %}
{% block title %}Users Admin Nature In Pots{% endblock %}
{% block content %}
<h1>Users</h1>
<form method="get" class="row g-2 mb-3">
<div class="col-auto">
<input id="searchInput" type="text"
name="q"
value="{{ q }}"
class="form-control"
placeholder="Search by email…">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">Search</button>
</div>
<div class="col-auto form-check align-self-center">
<input id="showDeleted" type="checkbox"
class="form-check-input"
name="show_deleted"
value="1"
{% if show_deleted %}checked{% endif %}>
<label class="form-check-label" for="showDeleted">
Show deleted
</label>
</div>
</form>
<p>
<a href="{{ url_for('admin.create_user') }}"
class="btn btn-primary mb-3">
Create New User
</a>
</p>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th><th>Email</th><th>Role</th>
<th>Verified</th><th>Excluded</th><th>Status</th>
<th>Joined</th><th>Actions</th>
</tr>
</thead>
<tbody id="userTableBody">
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td>{{ u.email }}</td>
<td>{{ u.role }}</td>
<td>{{ '✓' if u.is_verified else '' }}</td>
<td>{{ '✓' if u.excluded_from_analytics else '' }}</td>
<td>
{% if u.is_banned %}
<span class="text-danger">Permanently Banned</span>
{% elif u.suspended_until and u.suspended_until > utcnow %}
<span class="text-warning">
Suspended until {{ u.suspended_until.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-success">Active</span>
{% endif %}
</td>
<td>{{ u.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('admin.edit_user',
user_id=u.id,
page=pagination.page,
show_deleted='1' if show_deleted else None,
q=q) }}"
class="btn btn-sm btn-primary">Edit</a>
{% if u.is_deleted %}
<form action="{{ url_for('admin.undelete_user',
user_id=u.id,
page=pagination.page,
show_deleted='1',
q=q) }}"
method="post" style="display:inline;">
<input type="hidden"
name="csrf_token"
value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm btn-success"
onclick="return confirm('Restore this user?');">
Restore
</button>
</form>
{% else %}
<form action="{{ url_for('admin.delete_user',
user_id=u.id,
page=pagination.page,
show_deleted='1' if show_deleted else None,
q=q) }}"
method="post" style="display:inline;">
<input type="hidden"
name="csrf_token"
value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm btn-danger"
onclick="return confirm('Delete this user?');">
Delete
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<nav id="paginationNav" aria-label="User list pagination">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link"
href="{{ url_for('admin.list_users',
page=pagination.prev_num,
show_deleted='1' if show_deleted else None,
q=q) }}">
Previous
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=2, right_edge=2,
left_current=2, right_current=2) %}
{% if p %}
{% if p == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ p }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link"
href="{{ url_for('admin.list_users',
page=p,
show_deleted='1' if show_deleted else None,
q=q) }}">
{{ p }}
</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link"
href="{{ url_for('admin.list_users',
page=pagination.next_num,
show_deleted='1' if show_deleted else None,
q=q) }}">
Next
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{# --- AJAX search script --- #}
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const showDeletedCheckbox = document.getElementById('showDeleted');
const tbody = document.getElementById('userTableBody');
const paginationNav = document.getElementById('paginationNav');
const listUrl = "{{ url_for('admin.list_users') }}";
let timer;
function doSearch() {
const q = searchInput.value;
const showDeleted = showDeletedCheckbox.checked ? '1' : '0';
fetch(`${listUrl}?q=${encodeURIComponent(q)}&show_deleted=${showDeleted}`, {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json())
.then(data => {
// rebuild rows
tbody.innerHTML = '';
data.users.forEach(u => {
let statusHtml;
if (u.status === 'banned') {
statusHtml = '<span class="text-danger">Permanently Banned</span>';
} else if (u.status === 'suspended') {
statusHtml = `<span class="text-warning">Suspended until ${u.suspended_until}</span>`;
} else {
statusHtml = '<span class="text-success">Active</span>';
}
const actionForms = u.is_deleted
? `<form action="/admin/users/${u.id}/undelete" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="${data.csrf_token}">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Restore this user?');">Restore</button>
</form>`
: `<form action="/admin/users/${u.id}/delete" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="${data.csrf_token}">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete this user?');">Delete</button>
</form>`;
tbody.insertAdjacentHTML('beforeend', `
<tr>
<td>${u.id}</td>
<td>${u.email}</td>
<td>${u.role}</td>
<td>${u.is_verified ? '✓' : ''}</td>
<td>${u.excluded_from_analytics ? '✓' : ''}</td>
<td>${statusHtml}</td>
<td>${u.created_at}</td>
<td>
<a href="/admin/users/${u.id}/edit" class="btn btn-sm btn-primary">Edit</a>
${actionForms}
</td>
</tr>
`);
});
// hide pagination when searching
paginationNav.style.display = q ? 'none' : '';
});
}
searchInput.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(doSearch, 300);
});
showDeletedCheckbox.addEventListener('change', () => {
clearTimeout(timer);
timer = setTimeout(doSearch, 0);
});
});
</script>
{% endblock %}

View File

@ -15,6 +15,13 @@ class User(db.Model, UserMixin):
excluded_from_analytics = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Soft-delete flag
is_deleted = db.Column(db.Boolean, nullable=False, default=False)
# Permanent ban flag
is_banned = db.Column(db.Boolean, nullable=False, default=False)
# Temporary suspension until this UTC datetime
suspended_until = db.Column(db.DateTime, nullable=True)
# Use back_populates, not backref
submitted_submissions = db.relationship(
"Submission",

View File

@ -17,6 +17,7 @@
main { flex: 1; }
footer { background: #f8f9fa; padding: 1rem 0; text-align: center; }
</style>
{% block styles %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4">
@ -100,7 +101,7 @@
{% if current_user.role == 'admin' %}
<a
class="btn btn-outline-danger me-3"
href="{{ url_for('admin.admin_dashboard') }}"
href="{{ url_for('admin.dashboard') }}"
>
Admin Dashboard
</a>
@ -214,5 +215,6 @@
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
></script>
{% block scripts %}{% endblock %}
</body>
</html>