From eda11a172a87021a0ea60b9c1d30398fe9c09914 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Fri, 23 Apr 2021 01:07:23 -0400 Subject: [PATCH 01/16] Create DB migrations --- migrations/README | 1 + migrations/alembic.ini | 50 +++++++++++ migrations/env.py | 90 +++++++++++++++++++ migrations/script.py.mako | 24 +++++ .../versions/4f95c173f1d9_initial_schema.py | 56 ++++++++++++ quotefault/__init__.py | 41 ++------- quotefault/models.py | 53 +++++++++++ 7 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/4f95c173f1d9_initial_schema.py create mode 100644 quotefault/models.py diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..42438a5 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,90 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option( + 'sqlalchemy.url', + str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = current_app.extensions['migrate'].db.engine + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/4f95c173f1d9_initial_schema.py b/migrations/versions/4f95c173f1d9_initial_schema.py new file mode 100644 index 0000000..36f0d50 --- /dev/null +++ b/migrations/versions/4f95c173f1d9_initial_schema.py @@ -0,0 +1,56 @@ +"""Initial schema + +Revision ID: 4f95c173f1d9 +Revises: +Create Date: 2021-04-23 01:02:40.157049 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4f95c173f1d9' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('api_key', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('hash', sa.String(length=64), nullable=True), + sa.Column('owner', sa.String(length=80), nullable=True), + sa.Column('reason', sa.String(length=120), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('hash'), + sa.UniqueConstraint('owner', 'reason', name='unique_key') + ) + op.create_table('quote', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('submitter', sa.String(length=80), nullable=False), + sa.Column('quote', sa.String(length=200), nullable=False), + sa.Column('speaker', sa.String(length=50), nullable=False), + sa.Column('quote_time', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('quote') + ) + op.create_table('vote', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('quote_id', sa.Integer(), nullable=True), + sa.Column('voter', sa.String(length=200), nullable=False), + sa.Column('direction', sa.Integer(), nullable=False), + sa.Column('updated_time', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['quote_id'], ['quote.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('vote') + op.drop_table('quote') + op.drop_table('api_key') + # ### end Alembic commands ### diff --git a/quotefault/__init__.py b/quotefault/__init__.py index 00d4ef9..4a351d6 100644 --- a/quotefault/__init__.py +++ b/quotefault/__init__.py @@ -6,6 +6,7 @@ import requests from csh_ldap import CSHLDAP from flask import Flask, render_template, request, flash, session, make_response +from flask_migrate import Migrate from flask_pyoidc.flask_pyoidc import OIDCAuthentication from flask_sqlalchemy import SQLAlchemy from sqlalchemy import func @@ -16,8 +17,14 @@ app.config.from_pyfile(os.path.join(os.getcwd(), "config.py")) else: app.config.from_pyfile(os.path.join(os.getcwd(), "config.env.py")) + #var representing the quote database the app is connected to db = SQLAlchemy(app) +migrate = Migrate(app, db) +app.logger.info('SQLAlchemy pointed at ' + repr(db.engine.url)) + +# pylint: disable=wrong-import-position +from .models import Quote, Vote # Disable SSL certificate verification warning requests.packages.urllib3.disable_warnings() @@ -38,40 +45,6 @@ from .mail import send_quote_notification_email -# create the quote table with all relevant columns -class Quote(db.Model): - id = db.Column(db.Integer, primary_key=True, nullable=False) - submitter = db.Column(db.String(80), nullable=False) - quote = db.Column(db.String(200), unique=True, nullable=False) - speaker = db.Column(db.String(50), nullable=False) - quote_time = db.Column(db.DateTime, nullable=False) - - # initialize a row for the Quote table - def __init__(self, submitter, quote, speaker): - self.quote_time = datetime.now() - self.submitter = submitter - self.quote = quote - self.speaker = speaker - - -class Vote(db.Model): - id = db.Column(db.Integer, primary_key=True, nullable=False) - quote_id = db.Column(db.ForeignKey("quote.id")) - voter = db.Column(db.String(200), nullable=False) - direction = db.Column(db.Integer, nullable=False) - updated_time = db.Column(db.DateTime, nullable=False) - - quote = db.relationship(Quote) - test = db.UniqueConstraint("quote_id", "voter") - - # initialize a row for the Vote table - def __init__(self, quote_id, voter, direction): - self.quote_id = quote_id - self.voter = voter - self.direction = direction - self.updated_time = datetime.now() - - def get_metadata(): uuid = str(session["userinfo"].get("sub", "")) uid = str(session["userinfo"].get("preferred_username", "")) diff --git a/quotefault/models.py b/quotefault/models.py new file mode 100644 index 0000000..6b47a3f --- /dev/null +++ b/quotefault/models.py @@ -0,0 +1,53 @@ +""" +Defines the application's database models +""" + +from sqlalchemy import UniqueConstraint + +from quotefault import db + +# create the quote table with all relevant columns +class Quote(db.Model): + id = db.Column(db.Integer, primary_key=True, nullable=False) + submitter = db.Column(db.String(80), nullable=False) + quote = db.Column(db.String(200), unique=True, nullable=False) + speaker = db.Column(db.String(50), nullable=False) + quote_time = db.Column(db.DateTime, nullable=False) + + # initialize a row for the Quote table + def __init__(self, submitter, quote, speaker): + self.quote_time = datetime.now() + self.submitter = submitter + self.quote = quote + self.speaker = speaker + + +class Vote(db.Model): + id = db.Column(db.Integer, primary_key=True, nullable=False) + quote_id = db.Column(db.ForeignKey("quote.id")) + voter = db.Column(db.String(200), nullable=False) + direction = db.Column(db.Integer, nullable=False) + updated_time = db.Column(db.DateTime, nullable=False) + + quote = db.relationship(Quote) + test = db.UniqueConstraint("quote_id", "voter") + + # initialize a row for the Vote table + def __init__(self, quote_id, voter, direction): + self.quote_id = quote_id + self.voter = voter + self.direction = direction + self.updated_time = datetime.now() + + +class APIKey(db.Model): + id = db.Column(db.Integer, primary_key=True) + hash = db.Column(db.String(64), unique=True) + owner = db.Column(db.String(80)) + reason = db.Column(db.String(120)) + __table_args__ = (UniqueConstraint('owner', 'reason', name='unique_key'),) + + def __init__(self, owner, reason): + self.hash = binascii.b2a_hex(os.urandom(10)) + self.owner = owner + self.reason = reason From bf96a8cc0e6d4e7ef259e9f12bcdf90c9165e534 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Fri, 23 Apr 2021 01:28:28 -0400 Subject: [PATCH 02/16] Define report table --- .../versions/76898f8ac346_add_reports.py | 33 +++++++++++++++++++ quotefault/models.py | 18 +++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/76898f8ac346_add_reports.py diff --git a/migrations/versions/76898f8ac346_add_reports.py b/migrations/versions/76898f8ac346_add_reports.py new file mode 100644 index 0000000..8257fd2 --- /dev/null +++ b/migrations/versions/76898f8ac346_add_reports.py @@ -0,0 +1,33 @@ +"""Add reports + +Revision ID: 76898f8ac346 +Revises: 4f95c173f1d9 +Create Date: 2021-04-23 01:18:03.934757 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '76898f8ac346' +down_revision = '4f95c173f1d9' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('report', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('quote_id', sa.Integer(), nullable=True), + sa.Column('reporter', sa.Text(), nullable=False), + sa.Column('reason', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['quote_id'], ['quote.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('quote', sa.Column('hidden', sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column('quote', 'hidden') + op.drop_table('report') diff --git a/quotefault/models.py b/quotefault/models.py index 6b47a3f..e6d60aa 100644 --- a/quotefault/models.py +++ b/quotefault/models.py @@ -8,11 +8,16 @@ # create the quote table with all relevant columns class Quote(db.Model): + __tablename__ = 'quote' id = db.Column(db.Integer, primary_key=True, nullable=False) submitter = db.Column(db.String(80), nullable=False) quote = db.Column(db.String(200), unique=True, nullable=False) speaker = db.Column(db.String(50), nullable=False) quote_time = db.Column(db.DateTime, nullable=False) + hidden = db.Column(db.Boolean, default=False) + + votes = db.relationship('Vote', back_populates='quote_id') + reports = db.relationship('Report', back_populates='quote_id') # initialize a row for the Quote table def __init__(self, submitter, quote, speaker): @@ -23,13 +28,14 @@ def __init__(self, submitter, quote, speaker): class Vote(db.Model): + __tablename__ = 'vote' id = db.Column(db.Integer, primary_key=True, nullable=False) quote_id = db.Column(db.ForeignKey("quote.id")) voter = db.Column(db.String(200), nullable=False) direction = db.Column(db.Integer, nullable=False) updated_time = db.Column(db.DateTime, nullable=False) - quote = db.relationship(Quote) + quote = db.relationship(Quote, back_populates='votes') test = db.UniqueConstraint("quote_id", "voter") # initialize a row for the Vote table @@ -41,6 +47,7 @@ def __init__(self, quote_id, voter, direction): class APIKey(db.Model): + __tablename__ = 'api_key' id = db.Column(db.Integer, primary_key=True) hash = db.Column(db.String(64), unique=True) owner = db.Column(db.String(80)) @@ -51,3 +58,12 @@ def __init__(self, owner, reason): self.hash = binascii.b2a_hex(os.urandom(10)) self.owner = owner self.reason = reason + +class Report(db.Model): + __tablename__ = 'report' + id = db.Column(db.Integer, primary_key=True) + quote_id = db.Column(db.Integer, db.ForeignKey('quote.id')) + reporter = db.Column(db.Text, nullable=False) + reason = db.Column(db.Text, nullable=False) + + quote = db.relationship(Quote, back_populates='reports') From f8d62c25c56f3403ee550d2309b383310261c4c4 Mon Sep 17 00:00:00 2001 From: Max Meinhold Date: Fri, 23 Apr 2021 01:43:20 -0400 Subject: [PATCH 03/16] Hide hidden quotes --- quotefault/__init__.py | 56 ++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/quotefault/__init__.py b/quotefault/__init__.py index 4a351d6..4a4ecff 100644 --- a/quotefault/__init__.py +++ b/quotefault/__init__.py @@ -155,23 +155,33 @@ def submit(): return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members), 200 +def get_quote_query(speaker: str = "", submitter: str = "", include_hidden: bool = False): + """Return a query based on the args, with vote count attached to the quotes""" + + # Get all the quotes with their votes + quote_query = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote) + + # Put the most recent first + quote_query = quote_query.order_by(Quote.quote_time.desc()) + + # Filter hidden quotes + if not include_hidden: + quote_query = quote_query.filter(Quote.hidden == False) + + # Filter by speaker and submitter, if applicable + if request.args.get('speaker'): + quote_query = quote_query.filter(Quote.speaker == request.args.get('speaker')) + if request.args.get('submitter'): + quote_query = quote_query.filter(Quote.submitter == request.args.get('submitter')) + + return quote_query + # display first 20 stored quotes @app.route('/storage', methods=['GET']) @auth.oidc_auth def get(): - metadata = get_metadata() - metadata['submitter'] = request.args.get('submitter') # get submitter from url query string - metadata['speaker'] = request.args.get('speaker') # get speaker from url query string - - #return the first 20 quotes according to query strings (or lack thereof), as well as their associated vote value - if metadata['speaker'] is not None and metadata['submitter'] is not None: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).filter(Quote.submitter == metadata['submitter'], Quote.speaker == metadata['speaker']).limit(20).all() - elif metadata['submitter'] is not None: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).filter(Quote.submitter == metadata['submitter']).limit(20).all() - elif metadata['speaker'] is not None: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).filter(Quote.speaker == metadata['speaker']).limit(20).all() - else: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).limit(20).all() + # Get the most recent 20 quotes + quotes = get_quote_query(speaker = request.args.get('speaker'), submitter = request.args.get('submitter')).limit(20).all() #tie any votes the user has made to their uid user_votes = db.session.query(Vote).filter(Vote.voter == metadata['submitter']).all() @@ -179,7 +189,7 @@ def get(): return render_template( 'bootstrap/storage.html', quotes=quotes, - metadata=metadata, + metadata=get_metadata(), user_votes=user_votes ) @@ -189,27 +199,15 @@ def get(): @auth.oidc_auth def additional_quotes(): - metadata = get_metadata() - metadata['submitter'] = request.args.get('submitter') # get submitter from url query string - metadata['speaker'] = request.args.get('speaker') # get speaker from url query string - - #return quotes according to query strings (or lack thereof) - if metadata['speaker'] is not None and metadata['submitter'] is not None: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).filter(Quote.submitter == metadata['submitter'], Quote.speaker == metadata['speaker']).all() - elif metadata['submitter'] is not None: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).filter(Quote.submitter == metadata['submitter']).all() - elif metadata['speaker'] is not None: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).filter(Quote.speaker == metadata['speaker']).all() - else: - quotes = db.session.query(Quote, func.sum(Vote.direction).label('votes')).outerjoin(Vote).group_by(Quote).order_by(Quote.quote_time.desc()).all() + # Get all the quotes + quotes = get_quote_query(speaker = request.args.get('speaker'), submitter = request.args.get('submitter')).all() #tie any votes the user has made to their uid user_votes = db.session.query(Vote).filter(Vote.voter == metadata['uid']).all() - return render_template( 'bootstrap/additional_quotes.html', quotes=quotes[20:], - metadata=metadata, + metadata=get_metadata(), user_votes=user_votes ) From 2c5e74b4de855f22056d01ee958d475869bf184d Mon Sep 17 00:00:00 2001 From: Joseph Abbate Date: Mon, 8 Nov 2021 00:47:31 -0500 Subject: [PATCH 04/16] Add Report, Hide, and Review Functions Report Button allows quote to be sent to RTP/Eboard for review Hide prevents the quote from being seen by anyone except for speaker, submitter, and Eboard/RTPs Review is the process that Eboard/RTP view reported quotes to decide to keep or hide --- alembic.ini | 74 +++++++++++ config.env.py | 4 + config.sample.py | 3 + ...8a4c7fbcc2_report_reviewed_column_added.py | 25 ++++ ...5c78b9d1d06e_add_report_reviewed_column.py | 24 ++++ quotefault/__init__.py | 124 ++++++++++++++++-- quotefault/ldap.py | 12 ++ quotefault/mail.py | 17 ++- quotefault/models.py | 19 ++- quotefault/templates/bootstrap/admin.html | 55 ++++++++ quotefault/templates/bootstrap/base.html | 14 ++ quotefault/templates/bootstrap/hidden.html | 100 ++++++++++++++ quotefault/templates/bootstrap/storage.html | 98 +++++++++++++- quotefault/templates/extend/base.html | 29 ++++ quotefault/templates/extend/email.html | 17 +++ quotefault/templates/mail/notif.html | 9 ++ quotefault/templates/mail/notif.txt | 4 + quotefault/templates/mail/report.html | 12 ++ quotefault/templates/mail/report.txt | 6 + requirements.txt | 3 +- 20 files changed, 627 insertions(+), 22 deletions(-) create mode 100644 alembic.ini create mode 100644 migrations/versions/3b8a4c7fbcc2_report_reviewed_column_added.py create mode 100644 migrations/versions/5c78b9d1d06e_add_report_reviewed_column.py create mode 100644 quotefault/templates/bootstrap/admin.html create mode 100644 quotefault/templates/bootstrap/hidden.html create mode 100644 quotefault/templates/extend/base.html create mode 100644 quotefault/templates/extend/email.html create mode 100644 quotefault/templates/mail/notif.html create mode 100644 quotefault/templates/mail/notif.txt create mode 100644 quotefault/templates/mail/report.html create mode 100644 quotefault/templates/mail/report.txt diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..33b6827 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,74 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/config.env.py b/config.env.py index fc67ded..60c0ee0 100644 --- a/config.env.py +++ b/config.env.py @@ -23,5 +23,9 @@ # CSH_LDAP credentials LDAP_BIND_DN = os.environ.get("LDAP_BIND_DN", default="cn=quotefault,ou=Apps,dc=csh,dc=rit,dc=edu") LDAP_BIND_PW = os.environ.get("LDAP_BIND_PW", default=None) +MAIL_USERNAME = os.environ.get("MAIL_USERNAME", default=None) MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD", default=None) MAIL_SERVER = os.environ.get("MAIL_SERVER", default=None) +MAIL_PORT = os.environ.get("MAIL_PORT", default=465) +MAIL_USE_SSL = True + diff --git a/config.sample.py b/config.sample.py index 049a631..b8fa092 100644 --- a/config.sample.py +++ b/config.sample.py @@ -20,5 +20,8 @@ # CSH_LDAP credentials LDAP_BIND_DN = '' LDAP_BIND_PW = '' +MAIL_USERNAME = '' MAIL_PASSWORD = '' MAIL_SERVER = '' +MAIL_PORT = 465 +MAIL_USE_SSL = True \ No newline at end of file diff --git a/migrations/versions/3b8a4c7fbcc2_report_reviewed_column_added.py b/migrations/versions/3b8a4c7fbcc2_report_reviewed_column_added.py new file mode 100644 index 0000000..7a65e72 --- /dev/null +++ b/migrations/versions/3b8a4c7fbcc2_report_reviewed_column_added.py @@ -0,0 +1,25 @@ +"""Report Reviewed Column Added + +Revision ID: 3b8a4c7fbcc2 +Revises: 76898f8ac346 +Create Date: 2021-11-07 22:31:04.835460 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3b8a4c7fbcc2' +down_revision = '76898f8ac346' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('report', sa.Column('reviewed', sa.Boolean(), nullable=False)) + + +def downgrade(): + op.drop_column('report', 'reviewed') + diff --git a/migrations/versions/5c78b9d1d06e_add_report_reviewed_column.py b/migrations/versions/5c78b9d1d06e_add_report_reviewed_column.py new file mode 100644 index 0000000..da7c282 --- /dev/null +++ b/migrations/versions/5c78b9d1d06e_add_report_reviewed_column.py @@ -0,0 +1,24 @@ +"""Add Report Reviewed Column + +Revision ID: 5c78b9d1d06e +Revises: 76898f8ac346 +Create Date: 2021-11-07 22:28:49.719690 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5c78b9d1d06e' +down_revision = '76898f8ac346' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/quotefault/__init__.py b/quotefault/__init__.py index 4a4ecff..a303d13 100644 --- a/quotefault/__init__.py +++ b/quotefault/__init__.py @@ -3,13 +3,20 @@ import subprocess from datetime import datetime + + import requests from csh_ldap import CSHLDAP from flask import Flask, render_template, request, flash, session, make_response +from flask_mail import Mail, Message from flask_migrate import Migrate from flask_pyoidc.flask_pyoidc import OIDCAuthentication from flask_sqlalchemy import SQLAlchemy from sqlalchemy import func +from sqlalchemy.orm.query import Query +from werkzeug.utils import redirect + +from config import LDAP_BIND_DN app = Flask(__name__) # look for a config file to associate with a db/port/ip/servername @@ -21,10 +28,11 @@ #var representing the quote database the app is connected to db = SQLAlchemy(app) migrate = Migrate(app, db) +mail_client = Mail(app) app.logger.info('SQLAlchemy pointed at ' + repr(db.engine.url)) # pylint: disable=wrong-import-position -from .models import Quote, Vote +from .models import Quote, Vote, Report # Disable SSL certificate verification warning requests.packages.urllib3.disable_warnings() @@ -38,12 +46,10 @@ # Create CSHLDAP connection _ldap = CSHLDAP(app.config["LDAP_BIND_DN"], app.config["LDAP_BIND_PW"]) - app.secret_key = 'submission' # allows message flashing, var not actually used -from .ldap import get_all_members, ldap_get_member -from .mail import send_quote_notification_email - +from .ldap import get_all_members, is_member_of_group +from .mail import send_quote_notification_email, send_report_email def get_metadata(): uuid = str(session["userinfo"].get("sub", "")) @@ -54,16 +60,15 @@ def get_metadata(): "uuid": uuid, "uid": uid, "version": version, - "plug": plug + "plug": plug, + "is_admin" : is_member_of_group(uid,'eboard') or is_member_of_group(uid,'rtp') } return metadata - # run the main page by creating the table(s) in the CSH serverspace and rendering the mainpage template @app.route('/', methods=['GET']) @auth.oidc_auth def main(): - db.create_all() metadata = get_metadata() all_members = get_all_members() return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members) @@ -116,8 +121,9 @@ def update_settings(): @app.route('/submit', methods=['POST']) @auth.oidc_auth def submit(): - # submitter will grab UN from OIDC when linked to it + #submitter will grab UN from OIDC when linked to it submitter = session['userinfo'].get('preferred_username', '') + metadata = get_metadata() all_members = get_all_members() quote = request.form['quoteString'] @@ -145,7 +151,7 @@ def submit(): db.session.commit() # Send email to person quoted if app.config['MAIL_SERVER'] != '': - send_quote_notification_email(app, speaker) + send_quote_notification_email(app, mail_client, speaker) # create a message to flash for successful submission flash('Submission Successful!') # return something to complete submission @@ -180,16 +186,18 @@ def get_quote_query(speaker: str = "", submitter: str = "", include_hidden: bool @app.route('/storage', methods=['GET']) @auth.oidc_auth def get(): + metadata = get_metadata() + # Get the most recent 20 quotes quotes = get_quote_query(speaker = request.args.get('speaker'), submitter = request.args.get('submitter')).limit(20).all() #tie any votes the user has made to their uid - user_votes = db.session.query(Vote).filter(Vote.voter == metadata['submitter']).all() + user_votes = db.session.query(Vote).filter(Vote.voter == metadata['uid']).all() return render_template( 'bootstrap/storage.html', quotes=quotes, - metadata=get_metadata(), + metadata=metadata, user_votes=user_votes ) @@ -199,6 +207,8 @@ def get(): @auth.oidc_auth def additional_quotes(): + metadata = get_metadata() + # Get all the quotes quotes = get_quote_query(speaker = request.args.get('speaker'), submitter = request.args.get('submitter')).all() @@ -208,6 +218,94 @@ def additional_quotes(): return render_template( 'bootstrap/additional_quotes.html', quotes=quotes[20:], - metadata=get_metadata(), + metadata=metadata, user_votes=user_votes ) + +@app.route('/report/', methods=['POST']) +@auth.oidc_auth +def report(id): + metadata = get_metadata() + existing_report = Report.query.filter(Report.reporter==metadata['uid'], Report.quote_id==id).first() + if existing_report: + flash("You already submitted a report for this Quote!") + return redirect('/storage') + new_report = Report(id, metadata['uid'], None) + db.session.add(new_report) + db.session.commit() + if app.config['MAIL_SERVER'] != '': + send_report_email( app, mail_client, metadata['uid'], Quote.query.get(id) ) + + flash("Report Successful!") + return redirect('/storage') + +@app.route('/review', methods=['GET']) +@auth.oidc_auth +def review(): + metadata = get_metadata() + if metadata['is_admin']: + # Get the most recent 20 quotes + reports = Report.query.filter(Report.reviewed==False).all() + + return render_template( + 'bootstrap/admin.html', + reports=reports, + metadata=metadata + ) + return redirect('/') + +@app.route('/review//', methods=['POST']) +@auth.oidc_auth +def review_submit(id, result): + metadata = get_metadata() + result = int(result) + if metadata['is_admin']: + # 1 = Keep, 0 = Hide + if result: + report = Report.query.get(id) + report.reviewed = True + db.session.commit() + flash("Report Completed: Quote Kept") + else: + report = Report.query.get(id) + report.quote.hidden = True + report.reviewed = True + db.session.commit() + flash("Report Completed: Quote Hidden") + return redirect('/review') + return redirect('/') + +@app.route('/hide/', methods=['POST']) +@auth.oidc_auth +def hide(id): + quote = Quote.query.get(id) + quote.hidden = True + db.session.commit() + flash("Quote Hidden!") + return redirect('/storage') + +@app.route('/unhide/', methods=['POST']) +@auth.oidc_auth +def unhide(id): + metadata = get_metadata() + if metadata['is_admin']: + quote = Quote.query.get(id) + quote.hidden = False + db.session.commit() + flash("Quote Unhidden!") + return redirect('/hidden') + return redirect('/') + +@app.route('/hidden', methods=['GET']) +@auth.oidc_auth +def hidden(): + metadata = get_metadata() + if metadata['is_admin']: + quotes = Quote.query.filter( Quote.hidden ).all() + else: + quotes = Quote.query.filter( (Quote.speaker == metadata['uid']) | (Quote.submitter == metadata['uid'] ) ).filter( Quote.hidden ).all() + return render_template( + 'bootstrap/hidden.html', + quotes=quotes, + metadata=metadata + ) \ No newline at end of file diff --git a/quotefault/ldap.py b/quotefault/ldap.py index f1301bc..54b1bc6 100644 --- a/quotefault/ldap.py +++ b/quotefault/ldap.py @@ -1,5 +1,9 @@ from functools import lru_cache from quotefault import _ldap, app +from typing import Dict, List +import ldap as pyldap # type: ignore +from typing import Optional, List, Dict +from csh_ldap import CSHLDAP, CSHMember @lru_cache(maxsize=8192) @@ -22,3 +26,11 @@ def get_display_name(username): except: return username return dict(get_display_name=get_display_name) + +def is_member_of_group(uid: str, group: str) -> bool: + member = ldap_get_member(uid) + group_list = member.get("memberOf") + for group_dn in group_list: + if group == group_dn.split(",")[0][3:]: + return True + return False \ No newline at end of file diff --git a/quotefault/mail.py b/quotefault/mail.py index c36d1c2..74e73f6 100644 --- a/quotefault/mail.py +++ b/quotefault/mail.py @@ -1,10 +1,22 @@ +from flask_mail import Message +from flask import render_template import smtplib import os from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +def send_report_email(app, mail_client, reporter, quote): + recipients = ["",""] + msg = Message(subject='New QuoteFault Report', + sender=app.config.get('MAIL_USERNAME'), + recipients=recipients) + template = 'mail/report' + msg.body = render_template(template + '.txt', reporter = reporter, quote = quote ) + msg.html = render_template(template + '.html', reporter = reporter, quote = quote ) + mail_client.send(msg) + def send_email(app, toaddr, subject, body): - fromaddr = "quotefault@csh.rit.edu" + fromaddr = app.config['MAIL_USERNAME'] msg = MIMEMultipart() msg['From'] = fromaddr msg['To'] = toaddr @@ -18,10 +30,9 @@ def send_email(app, toaddr, subject, body): server.sendmail(fromaddr, toaddr, text) server.quit() - def send_quote_notification_email(app, user): toaddr = "{}@csh.rit.edu".format(user) subject = "You've been quoted" body = "Somebody quoted you in Quotefault!\n\n" body += "Check Quotefault to see what you were caught saying!" - send_email(app, toaddr, subject, body) + send_email(app, toaddr, subject, body) \ No newline at end of file diff --git a/quotefault/models.py b/quotefault/models.py index e6d60aa..dd3ab8e 100644 --- a/quotefault/models.py +++ b/quotefault/models.py @@ -3,8 +3,12 @@ """ from sqlalchemy import UniqueConstraint +from sqlalchemy.sql.expression import nullslast from quotefault import db +from datetime import datetime +import os +import binascii # create the quote table with all relevant columns class Quote(db.Model): @@ -16,8 +20,8 @@ class Quote(db.Model): quote_time = db.Column(db.DateTime, nullable=False) hidden = db.Column(db.Boolean, default=False) - votes = db.relationship('Vote', back_populates='quote_id') - reports = db.relationship('Report', back_populates='quote_id') + votes = db.relationship('Vote', back_populates='quote') + reports = db.relationship('Report', back_populates='quote') # initialize a row for the Quote table def __init__(self, submitter, quote, speaker): @@ -62,8 +66,15 @@ def __init__(self, owner, reason): class Report(db.Model): __tablename__ = 'report' id = db.Column(db.Integer, primary_key=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quote.id')) + quote_id = db.Column(db.Integer, db.ForeignKey('quote.id'), nullable=False) reporter = db.Column(db.Text, nullable=False) - reason = db.Column(db.Text, nullable=False) + reason = db.Column(db.Text, nullable=True) + reviewed = db.Column(db.Boolean, nullable=False, default=False) quote = db.relationship(Quote, back_populates='reports') + + def __init__(self, quote_id, reporter, reason): + self.hash = binascii.b2a_hex(os.urandom(10)) + self.quote_id = quote_id + self.reporter = reporter + self.reason = reason \ No newline at end of file diff --git a/quotefault/templates/bootstrap/admin.html b/quotefault/templates/bootstrap/admin.html new file mode 100644 index 0000000..6e3d422 --- /dev/null +++ b/quotefault/templates/bootstrap/admin.html @@ -0,0 +1,55 @@ +{% extends "bootstrap/base.html" %} + +{% block styles %} + +{% endblock %} + +{% block body %} +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + + {% endfor %} + {% endif %} + {% endwith %} + {% for report in reports %} +
+
+ "{{ report.quote.quote }}" - {{ get_display_name(report.quote.speaker) }} +
+ Reported by: {{report.reporter}} +
+ +
+ {% endfor %} +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/quotefault/templates/bootstrap/base.html b/quotefault/templates/bootstrap/base.html index 5c3d877..6e451a1 100644 --- a/quotefault/templates/bootstrap/base.html +++ b/quotefault/templates/bootstrap/base.html @@ -62,6 +62,20 @@ Storage + + {% if metadata['is_admin'] %} + + {% endif %}