diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e78b7c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# DATABASE_URL=sqlite:////home/moments/database/data.db +# MAIL_SERVER=smtp.example.com +# MAIL_USERNAME=example +# MAIL_PASSWORD=example-password diff --git a/moments/blueprints/admin.py b/moments/blueprints/admin.py index 32be69e..30268e3 100644 --- a/moments/blueprints/admin.py +++ b/moments/blueprints/admin.py @@ -1,4 +1,4 @@ -from flask import Blueprint, current_app, flash, render_template, request, abort +from flask import Blueprint, current_app, flash, render_template, request, abort, redirect, url_for from flask_login import login_required from sqlalchemy import func, select @@ -163,10 +163,12 @@ def manage_photo(order): per_page = current_app.config['MOMENTS_MANAGE_PHOTO_PER_PAGE'] order_rule = 'flag' if order == 'by_time': - pagination = db.paginate(select(Photo).order_by(Photo.created_at.desc()), page=page, per_page=per_page) + pagination = db.paginate(select(Photo).order_by(Photo.created_at.desc()), page=page, per_page=per_page, error_out=False) order_rule = 'time' else: - pagination = db.paginate(select(Photo).order_by(Photo.flag.desc()), page=page, per_page=per_page) + pagination = db.paginate(select(Photo).order_by(Photo.flag.desc()), page=page, per_page=per_page, error_out=False) + if page > pagination.pages: + return redirect(url_for('.manage_photo', page=pagination.pages, order_rule=order_rule)) photos = pagination.items return render_template('admin/manage_photo.html', pagination=pagination, photos=photos, order_rule=order_rule) @@ -191,9 +193,33 @@ def manage_comment(order): per_page = current_app.config['MOMENTS_MANAGE_COMMENT_PER_PAGE'] order_rule = 'flag' if order == 'by_time': - pagination = db.paginate(select(Comment).order_by(Comment.created_at.desc()), page=page, per_page=per_page) + pagination = db.paginate(select(Comment).order_by(Comment.created_at.desc()), page=page, per_page=per_page, error_out=False) order_rule = 'time' else: - pagination = db.paginate(select(Comment).order_by(Comment.flag.desc()), page=page, per_page=per_page) + pagination = db.paginate(select(Comment).order_by(Comment.flag.desc()), page=page, per_page=per_page, error_out=False) + if page > pagination.pages: + return redirect(url_for('.manage_comment', page=pagination.pages, order_rule=order_rule)) comments = pagination.items return render_template('admin/manage_comment.html', pagination=pagination, comments=comments, order_rule=order_rule) + + +@admin_bp.route('/delete/photo/', methods=['POST']) +@login_required +@permission_required('MODERATE') +def delete_photo(photo_id): + photo = db.session.get(Photo, photo_id) or abort(404) + db.session.delete(photo) + db.session.commit() + flash('Photo deleted.', 'info') + return redirect_back() + + +@admin_bp.route('/delete/comment/', methods=['POST']) +@login_required +@permission_required('MODERATE') +def delete_comment(comment_id): + comment = db.session.get(Comment, comment_id) or abort(404) + db.session.delete(comment) + db.session.commit() + flash('Comment deleted.', 'info') + return redirect_back() diff --git a/moments/core/commands.py b/moments/core/commands.py index 81bfebb..1b48a95 100644 --- a/moments/core/commands.py +++ b/moments/core/commands.py @@ -2,7 +2,6 @@ from moments.core.extensions import db from moments.models import Role -from moments.lorem import fake_admin, fake_collect, fake_comment, fake_follow, fake_photo, fake_tag, fake_user def register_commands(app): @@ -35,6 +34,8 @@ def init_app_command(): @click.option('--comment', default=100, help='Quantity of comments, default is 100.') def lorem_command(user, follow, photo, tag, collect, comment): """Generate fake data.""" + from moments.lorem import fake_admin, fake_collect, fake_comment, fake_follow, fake_photo, fake_tag, fake_user + db.drop_all() db.create_all() diff --git a/moments/core/errors.py b/moments/core/errors.py index 5fc1f06..56eb5eb 100644 --- a/moments/core/errors.py +++ b/moments/core/errors.py @@ -4,25 +4,26 @@ def register_error_handlers(app): @app.errorhandler(400) - def bad_request(e): - return render_template('errors/400.html'), 400 + def bad_request(error): + return render_template('errors/400.html', description=error.description), 400 @app.errorhandler(403) - def forbidden(e): - return render_template('errors/403.html'), 403 + def forbidden(error): + return render_template('errors/403.html', description=error.description), 403 @app.errorhandler(404) - def page_not_found(e): - return render_template('errors/404.html'), 404 + def page_not_found(error): + return render_template('errors/404.html', description=error.description), 404 @app.errorhandler(413) - def request_entity_too_large(e): - return render_template('errors/413.html'), 413 + def request_entity_too_large(error): + return render_template('errors/413.html', description=error.description), 413 @app.errorhandler(500) - def internal_server_error(e): - return render_template('errors/500.html'), 500 + def internal_server_error(error): + return render_template('errors/500.html', description=error.description), 500 @app.errorhandler(CSRFError) - def handle_csrf_error(e): - return render_template('errors/400.html', description=e.description), 500 + def handle_csrf_error(error): + description = 'Session expired, return last page and try again.' + return render_template('errors/400.html', description=description), 500 diff --git a/moments/emails.py b/moments/emails.py index 0cde98d..fd6f038 100644 --- a/moments/emails.py +++ b/moments/emails.py @@ -12,6 +12,12 @@ def _send_async_mail(app, message): def send_mail(to, subject, template, **kwargs): + if current_app.debug: + current_app.logger.debug('Skip sending email in debug mode.') + current_app.logger.debug(f'To: {to}') + current_app.logger.debug(f'Subject: {subject}') + current_app.logger.debug(f'Template: {template}') + return message = Message(current_app.config['MOMENTS_MAIL_SUBJECT_PREFIX'] + subject, recipients=[to]) message.body = render_template(template + '.txt', **kwargs) message.html = render_template(template + '.html', **kwargs) diff --git a/moments/forms/user.py b/moments/forms/user.py index e8a4de2..31cdd28 100644 --- a/moments/forms/user.py +++ b/moments/forms/user.py @@ -3,7 +3,7 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired from sqlalchemy import select from wtforms import BooleanField, HiddenField, PasswordField, StringField, SubmitField, TextAreaField, ValidationError -from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional, Regexp +from wtforms.validators import DataRequired, URL, Email, EqualTo, Length, Optional, Regexp from moments.core.extensions import db from moments.models import User @@ -19,7 +19,7 @@ class EditProfileForm(FlaskForm): Regexp('^[a-zA-Z0-9]*$', message='The username should contain only a-z, A-Z and 0-9.'), ], ) - website = StringField('Website', validators=[Optional(), Length(0, 255)]) + website = StringField('Website', validators=[URL(), Optional(), Length(0, 255)]) location = StringField('City', validators=[Optional(), Length(0, 50)]) bio = TextAreaField('Bio', validators=[Optional(), Length(0, 120)]) submit = SubmitField() diff --git a/moments/models.py b/moments/models.py index 39c68c8..25f4737 100644 --- a/moments/models.py +++ b/moments/models.py @@ -24,7 +24,11 @@ class Permission(db.Model): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(30), unique=True) - roles: Mapped[List['Role']] = relationship(secondary=role_permission, back_populates='permissions') + roles: Mapped[List['Role']] = relationship( + secondary=role_permission, + back_populates='permissions', + passive_deletes=True + ) def __repr__(self): return f'Permission {self.id}: {self.name}' @@ -36,8 +40,12 @@ class Role(db.Model): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(30), unique=True) - users: WriteOnlyMapped['User'] = relationship(back_populates='role') - permissions: Mapped[List['Permission']] = relationship(secondary=role_permission, back_populates='roles') + users: WriteOnlyMapped['User'] = relationship(back_populates='role', passive_deletes=True) + permissions: Mapped[List['Permission']] = relationship( + secondary=role_permission, + back_populates='roles', + passive_deletes=True + ) @staticmethod def init_role(): @@ -293,7 +301,7 @@ class Photo(db.Model): collections: WriteOnlyMapped['Collection'] = relationship( back_populates='photo', cascade='all, delete-orphan', passive_deletes=True ) - tags: Mapped[List['Tag']] = relationship(secondary=photo_tag, back_populates='photos') + tags: Mapped[List['Tag']] = relationship(secondary=photo_tag, back_populates='photos', passive_deletes=True) @property def collectors_count(self): diff --git a/moments/templates/admin/manage_comment.html b/moments/templates/admin/manage_comment.html index 22ba07d..2789e87 100644 --- a/moments/templates/admin/manage_comment.html +++ b/moments/templates/admin/manage_comment.html @@ -55,7 +55,7 @@

Comments {{ comment.created_at }}
+ action="{{ url_for('admin.delete_comment', comment_id=comment.id, next=request.full_path) }}"> diff --git a/moments/templates/admin/manage_photo.html b/moments/templates/admin/manage_photo.html index 41aa368..6ff65db 100644 --- a/moments/templates/admin/manage_photo.html +++ b/moments/templates/admin/manage_photo.html @@ -72,7 +72,7 @@

Photos {{ photo.created_at }} + action="{{ url_for('admin.delete_photo', photo_id=photo.id, next=request.full_path) }}"> diff --git a/moments/templates/admin/manage_user.html b/moments/templates/admin/manage_user.html index 384bd64..44584f4 100644 --- a/moments/templates/admin/manage_user.html +++ b/moments/templates/admin/manage_user.html @@ -46,6 +46,7 @@

Users Avatars Name/username + Email Role Bio City @@ -58,6 +59,7 @@

Users {{ user.name }}
{{ user.username }} + {{ user.email }} {{ user.role.name }} {{ user.bio }} {{ user.location }} diff --git a/moments/templates/errors/403.html b/moments/templates/errors/403.html index 0b188e5..f1776da 100644 --- a/moments/templates/errors/403.html +++ b/moments/templates/errors/403.html @@ -8,7 +8,7 @@
403 Error
-

Forbidden

+

{{ description|default('Forbidden') }}