rough new admin panel, more changes pending
This commit is contained in:
@ -5,13 +5,16 @@ import json
|
|||||||
import glob
|
import glob
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import time
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask,request
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
# Load environment variables from .env or system
|
# Load environment variables from .env or system
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@ -114,6 +117,35 @@ def create_app():
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
return {'current_year': datetime.now().year}
|
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
|
app.jinja_env.globals['generate_image_url'] = generate_image_url
|
||||||
|
|
||||||
return app
|
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
|
from flask import Flask
|
||||||
|
|
||||||
def register_cli(app: Flask):
|
def register_cli(app: Flask):
|
||||||
# CLI entry-point for admin
|
# No CLI commands yet for admin plugin
|
||||||
pass
|
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",
|
"name": "admin",
|
||||||
"version": "1.0.0",
|
"version": "0.1.1",
|
||||||
"description": "Administration dashboard and plugin manager",
|
"description": "Admin panel plugin for Nature In Pots",
|
||||||
"entry_point": null
|
"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_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
|
@login_required
|
||||||
def admin_dashboard():
|
def dashboard():
|
||||||
if current_user.role != 'admin':
|
if current_user.role != 'admin':
|
||||||
return "Access denied", 403
|
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)
|
excluded_from_analytics = db.Column(db.Boolean, default=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
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
|
# Use back_populates, not backref
|
||||||
submitted_submissions = db.relationship(
|
submitted_submissions = db.relationship(
|
||||||
"Submission",
|
"Submission",
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
main { flex: 1; }
|
main { flex: 1; }
|
||||||
footer { background: #f8f9fa; padding: 1rem 0; text-align: center; }
|
footer { background: #f8f9fa; padding: 1rem 0; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4">
|
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm mb-4">
|
||||||
@ -100,7 +101,7 @@
|
|||||||
{% if current_user.role == 'admin' %}
|
{% if current_user.role == 'admin' %}
|
||||||
<a
|
<a
|
||||||
class="btn btn-outline-danger me-3"
|
class="btn btn-outline-danger me-3"
|
||||||
href="{{ url_for('admin.admin_dashboard') }}"
|
href="{{ url_for('admin.dashboard') }}"
|
||||||
>
|
>
|
||||||
Admin Dashboard
|
Admin Dashboard
|
||||||
</a>
|
</a>
|
||||||
@ -214,5 +215,6 @@
|
|||||||
<script
|
<script
|
||||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||||
></script>
|
></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
Reference in New Issue
Block a user