diff --git a/app/__init__.py b/app/__init__.py index 71e16a5..4ced773 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/nip.zip b/beta-0.0.1.zip similarity index 69% rename from nip.zip rename to beta-0.0.1.zip index 150116b..2db5a88 100644 Binary files a/nip.zip and b/beta-0.0.1.zip differ diff --git a/migrations/versions/01876f89899b_auto_migrate.py b/migrations/versions/01876f89899b_auto_migrate.py new file mode 100644 index 0000000..fbd7c57 --- /dev/null +++ b/migrations/versions/01876f89899b_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/03650b9a0f3a_auto_migrate.py b/migrations/versions/03650b9a0f3a_auto_migrate.py new file mode 100644 index 0000000..354750f --- /dev/null +++ b/migrations/versions/03650b9a0f3a_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/0514fb24a61e_auto_migrate.py b/migrations/versions/0514fb24a61e_auto_migrate.py new file mode 100644 index 0000000..86a22cf --- /dev/null +++ b/migrations/versions/0514fb24a61e_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/08ebb5577232_auto_migrate.py b/migrations/versions/08ebb5577232_auto_migrate.py new file mode 100644 index 0000000..1754483 --- /dev/null +++ b/migrations/versions/08ebb5577232_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/0efc1a18285f_auto_migrate.py b/migrations/versions/0efc1a18285f_auto_migrate.py new file mode 100644 index 0000000..0462bd3 --- /dev/null +++ b/migrations/versions/0efc1a18285f_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/30376c514135_auto_migrate.py b/migrations/versions/30376c514135_auto_migrate.py new file mode 100644 index 0000000..2250508 --- /dev/null +++ b/migrations/versions/30376c514135_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/390c977fe679_auto_migrate.py b/migrations/versions/390c977fe679_auto_migrate.py new file mode 100644 index 0000000..e88b619 --- /dev/null +++ b/migrations/versions/390c977fe679_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/4082065b932b_auto_migrate.py b/migrations/versions/4082065b932b_auto_migrate.py new file mode 100644 index 0000000..974abd6 --- /dev/null +++ b/migrations/versions/4082065b932b_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/51640ecd70ee_auto_migrate.py b/migrations/versions/51640ecd70ee_auto_migrate.py new file mode 100644 index 0000000..44615c0 --- /dev/null +++ b/migrations/versions/51640ecd70ee_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/807ca973d0cf_auto_migrate.py b/migrations/versions/807ca973d0cf_auto_migrate.py new file mode 100644 index 0000000..2ae5fe0 --- /dev/null +++ b/migrations/versions/807ca973d0cf_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/886aa234b3b7_auto_migrate.py b/migrations/versions/886aa234b3b7_auto_migrate.py new file mode 100644 index 0000000..c9c3564 --- /dev/null +++ b/migrations/versions/886aa234b3b7_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/892da654697c_auto_migrate.py b/migrations/versions/892da654697c_auto_migrate.py new file mode 100644 index 0000000..c266a01 --- /dev/null +++ b/migrations/versions/892da654697c_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/a69f613f9cd5_auto_migrate.py b/migrations/versions/a69f613f9cd5_auto_migrate.py new file mode 100644 index 0000000..f879130 --- /dev/null +++ b/migrations/versions/a69f613f9cd5_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/a79453aefa45_auto_migrate.py b/migrations/versions/a79453aefa45_auto_migrate.py new file mode 100644 index 0000000..7a251b6 --- /dev/null +++ b/migrations/versions/a79453aefa45_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/a87d4c1df4e5_auto_migrate.py b/migrations/versions/a87d4c1df4e5_auto_migrate.py new file mode 100644 index 0000000..39b52cc --- /dev/null +++ b/migrations/versions/a87d4c1df4e5_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/b56cd5e57987_auto_migrate.py b/migrations/versions/b56cd5e57987_auto_migrate.py new file mode 100644 index 0000000..1faa786 --- /dev/null +++ b/migrations/versions/b56cd5e57987_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/c23c31ae3a1d_auto_migrate.py b/migrations/versions/c23c31ae3a1d_auto_migrate.py new file mode 100644 index 0000000..3f81382 --- /dev/null +++ b/migrations/versions/c23c31ae3a1d_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/f7f41136c073_auto_migrate.py b/migrations/versions/f7f41136c073_auto_migrate.py new file mode 100644 index 0000000..cea8e3b --- /dev/null +++ b/migrations/versions/f7f41136c073_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/f81a9a44a7fb_auto_migrate.py b/migrations/versions/f81a9a44a7fb_auto_migrate.py new file mode 100644 index 0000000..460a5f6 --- /dev/null +++ b/migrations/versions/f81a9a44a7fb_auto_migrate.py @@ -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 ### diff --git a/migrations/versions/fe0ebdec3255_auto_migrate.py b/migrations/versions/fe0ebdec3255_auto_migrate.py new file mode 100644 index 0000000..937c2ff --- /dev/null +++ b/migrations/versions/fe0ebdec3255_auto_migrate.py @@ -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 ### diff --git a/plugins/admin/__init__.py b/plugins/admin/__init__.py index c0bb32a..864d480 100644 --- a/plugins/admin/__init__.py +++ b/plugins/admin/__init__.py @@ -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 diff --git a/plugins/admin/forms.py b/plugins/admin/forms.py new file mode 100644 index 0000000..6d79d8e --- /dev/null +++ b/plugins/admin/forms.py @@ -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') diff --git a/plugins/admin/models.py b/plugins/admin/models.py new file mode 100644 index 0000000..920bcd0 --- /dev/null +++ b/plugins/admin/models.py @@ -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)) diff --git a/plugins/admin/plugin.json b/plugins/admin/plugin.json index 2aa8b50..d90b4b7 100644 --- a/plugins/admin/plugin.json +++ b/plugins/admin/plugin.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "1.0.0", - "description": "Administration dashboard and plugin manager", - "entry_point": null -} \ No newline at end of file + "version": "0.1.1", + "description": "Admin panel plugin for Nature In Pots", + "entry_point": "register_cli" +} diff --git a/plugins/admin/routes.py b/plugins/admin/routes.py index ff8994c..45223e4 100644 --- a/plugins/admin/routes.py +++ b/plugins/admin/routes.py @@ -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//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//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//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'))) diff --git a/plugins/admin/templates/admin/admin_dashboard.html b/plugins/admin/templates/admin/admin_dashboard.html deleted file mode 100644 index 6cd35a0..0000000 --- a/plugins/admin/templates/admin/admin_dashboard.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'core_ui/base.html' %} -{% block title %}Admin Dashboard | Nature In Pots{% endblock %} -{% block content %} -

Admin Dashboard

-

Manage submissions, users, and plugin controls here.

- -{% endblock %} diff --git a/plugins/admin/templates/admin/dashboard.html b/plugins/admin/templates/admin/dashboard.html new file mode 100644 index 0000000..74cd1a1 --- /dev/null +++ b/plugins/admin/templates/admin/dashboard.html @@ -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 %} + +{% endblock %} + +{% block content %} +

Admin Dashboard

+ +{# ─── Tab Nav ─────────────────────────────────────────────────────── #} + + +
+ {# ─── Overview Pane ─────────────────────────────────────────────── #} +
+
+ {% 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), + ] %} +
+
+
+
{{ label }}
+

{{ value }}

+
+
+
+ {% endfor %} +
+ +
+
+
+
Sign-ups (Last 30 days)
+
+ +
+
+
+
+
+
User Status Breakdown
+
+ +
+
+
+
+ +
+
+
+
Top Routes (7d)
+
    + {% for r in top_routes %} +
  • + {{ r.path }} + {{ r.count }} +
  • + {% endfor %} +
+
+
+
+
+
Top Referrers (7d)
+
    + {% for ref, cnt in referrers.items() %} +
  • + {{ ref }} + {{ cnt }} +
  • + {% endfor %} +
+
+
+
+
+
Browsers (7d)
+
+ +
+
+
+
+
+ + {# ─── Stats Pane ────────────────────────────────────────────────── #} +
+
+
+
+
+
Total Plants
+

{{ total_plants }}

+
+
+
+
+
+
+
Active Plants
+

{{ active_plants }}

+
+
+
+
+
+
+
Total Logs
+

{{ total_logs }}

+
+
+
+
+
+
+
Logs (7d)
+

{{ logs_7d }}

+
+
+
+
+ +
+
Top 5 Plant Types
+
    + {% for ptype, cnt in popular_plants %} +
  • + {{ ptype }} + {{ cnt }} +
  • + {% endfor %} +
+
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/plugins/admin/templates/admin/users/form.html b/plugins/admin/templates/admin/users/form.html new file mode 100644 index 0000000..1fb287a --- /dev/null +++ b/plugins/admin/templates/admin/users/form.html @@ -0,0 +1,58 @@ +{% extends 'core_ui/base.html' %} +{% block title %}{{ action }} User – Admin – Nature In Pots{% endblock %} + +{% block content %} +

{{ action }} User

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.email.label(class="form-label") }} + {{ form.email(class="form-control") }} +
+ +
+ {{ form.password.label(class="form-label") }} + {{ form.password(class="form-control") }} + {% if action == 'Edit' %} + + Leave blank to keep current password. + + {% endif %} +
+ +
+ {{ form.role.label(class="form-label") }} + {{ form.role(class="form-select") }} +
+ +
+ {{ form.is_verified(class="form-check-input") }} + {{ form.is_verified.label(class="form-check-label") }} +
+ +
+ {{ form.excluded_from_analytics(class="form-check-input") }} + {{ form.excluded_from_analytics.label(class="form-check-label") }} +
+ +
+ +
+ {{ form.ban_type.label(class="form-label") }} + {{ form.ban_type(class="form-select") }} +
+ +
+ {{ form.suspend_days.label(class="form-label") }} + {{ form.suspend_days(class="form-control") }} + + Only used if “Temporarily Suspended” is selected (default 7 days) + +
+ + + Cancel +
+{% endblock %} diff --git a/plugins/admin/templates/admin/users/list.html b/plugins/admin/templates/admin/users/list.html new file mode 100644 index 0000000..c94a33a --- /dev/null +++ b/plugins/admin/templates/admin/users/list.html @@ -0,0 +1,244 @@ +{% extends 'core_ui/base.html' %} +{% block title %}Users – Admin – Nature In Pots{% endblock %} +{% block content %} +

Users

+ +
+
+ +
+
+ +
+
+ + +
+
+ +

+ + Create New User + +

+ + + + + + + + + + + {% for u in users %} + + + + + + + + + + + {% endfor %} + +
IDEmailRoleVerifiedExcludedStatusJoinedActions
{{ u.id }}{{ u.email }}{{ u.role }}{{ '✓' if u.is_verified else '' }}{{ '✓' if u.excluded_from_analytics else '' }} + {% if u.is_banned %} + Permanently Banned + {% elif u.suspended_until and u.suspended_until > utcnow %} + + Suspended until {{ u.suspended_until.strftime('%Y-%m-%d') }} + + {% else %} + Active + {% endif %} + {{ u.created_at.strftime('%Y-%m-%d') }} + Edit + {% if u.is_deleted %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+ + + +{# --- AJAX search script --- #} + +{% endblock %} diff --git a/plugins/auth/models.py b/plugins/auth/models.py index 431ac50..6cfaafd 100644 --- a/plugins/auth/models.py +++ b/plugins/auth/models.py @@ -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", diff --git a/plugins/core_ui/templates/core_ui/base.html b/plugins/core_ui/templates/core_ui/base.html index 29d4fec..7785299 100644 --- a/plugins/core_ui/templates/core_ui/base.html +++ b/plugins/core_ui/templates/core_ui/base.html @@ -17,6 +17,7 @@ main { flex: 1; } footer { background: #f8f9fa; padding: 1rem 0; text-align: center; } + {% block styles %}{% endblock %}