rough new admin panel, more changes pending
This commit is contained in:
@ -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 module‐load 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.
28
migrations/versions/01876f89899b_auto_migrate.py
Normal file
28
migrations/versions/01876f89899b_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/03650b9a0f3a_auto_migrate.py
Normal file
28
migrations/versions/03650b9a0f3a_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/0514fb24a61e_auto_migrate.py
Normal file
28
migrations/versions/0514fb24a61e_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/08ebb5577232_auto_migrate.py
Normal file
28
migrations/versions/08ebb5577232_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/0efc1a18285f_auto_migrate.py
Normal file
28
migrations/versions/0efc1a18285f_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/30376c514135_auto_migrate.py
Normal file
28
migrations/versions/30376c514135_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/390c977fe679_auto_migrate.py
Normal file
28
migrations/versions/390c977fe679_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/4082065b932b_auto_migrate.py
Normal file
28
migrations/versions/4082065b932b_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/51640ecd70ee_auto_migrate.py
Normal file
28
migrations/versions/51640ecd70ee_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/807ca973d0cf_auto_migrate.py
Normal file
28
migrations/versions/807ca973d0cf_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/886aa234b3b7_auto_migrate.py
Normal file
28
migrations/versions/886aa234b3b7_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/892da654697c_auto_migrate.py
Normal file
28
migrations/versions/892da654697c_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/a69f613f9cd5_auto_migrate.py
Normal file
28
migrations/versions/a69f613f9cd5_auto_migrate.py
Normal 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 ###
|
39
migrations/versions/a79453aefa45_auto_migrate.py
Normal file
39
migrations/versions/a79453aefa45_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/a87d4c1df4e5_auto_migrate.py
Normal file
28
migrations/versions/a87d4c1df4e5_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/b56cd5e57987_auto_migrate.py
Normal file
28
migrations/versions/b56cd5e57987_auto_migrate.py
Normal 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 ###
|
36
migrations/versions/c23c31ae3a1d_auto_migrate.py
Normal file
36
migrations/versions/c23c31ae3a1d_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/f7f41136c073_auto_migrate.py
Normal file
28
migrations/versions/f7f41136c073_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/f81a9a44a7fb_auto_migrate.py
Normal file
28
migrations/versions/f81a9a44a7fb_auto_migrate.py
Normal 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 ###
|
28
migrations/versions/fe0ebdec3255_auto_migrate.py
Normal file
28
migrations/versions/fe0ebdec3255_auto_migrate.py
Normal 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 ###
|
@ -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
31
plugins/admin/forms.py
Normal 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
17
plugins/admin/models.py
Normal 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))
|
@ -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"
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
# Grow‐logs
|
||||
total_logs = GrowLog.query.count()
|
||||
logs_7d = GrowLog.query.filter(GrowLog.created_at >= week_ago).count()
|
||||
|
||||
# Sign‐ups 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')))
|
||||
|
@ -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 %}
|
244
plugins/admin/templates/admin/dashboard.html
Normal file
244
plugins/admin/templates/admin/dashboard.html
Normal 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 %}
|
58
plugins/admin/templates/admin/users/form.html
Normal file
58
plugins/admin/templates/admin/users/form.html
Normal 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 %}
|
244
plugins/admin/templates/admin/users/list.html
Normal file
244
plugins/admin/templates/admin/users/list.html
Normal 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 %}
|
@ -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",
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user