diff --git a/app.py b/app.py index 3d15de6..3e70b78 100644 --- a/app.py +++ b/app.py @@ -4,3 +4,4 @@ app.run(host=app.config['IP'], port=app.config['PORT'], threaded = False) application = app + 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..ef351f6 100644 --- a/config.sample.py +++ b/config.sample.py @@ -20,5 +20,9 @@ # CSH_LDAP credentials LDAP_BIND_DN = '' LDAP_BIND_PW = '' +MAIL_USERNAME = '' MAIL_PASSWORD = '' MAIL_SERVER = '' +MAIL_PORT = 465 +MAIL_USE_SSL = True + diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..b2194f3 --- /dev/null +++ b/migrations/README @@ -0,0 +1,12 @@ +## What are Alembic and migrations? +Migrations give a versioned history of changes to the SQL Schema for this project. +A `revision` or `migration` defines changes to the structure of the database (such as adding/dropping tables or columns), as well as procedures for migrating existing data to the new structure. Migrations allow for safe(ish) rollbacks, and make it easier to make incremental changes to the database. + +## Applying migrations +Once you have your db URI in `config.py`, run `flask db upgrade` to bring the db up to the latest revision. + +## Creating new revisions +When you change `models.py`, you'll need to make a new migration. +Run `flask db revision -m ""` to autogenerate one. +You should go manually inspect the created revision to make sure it does what you expect. +Once you've got your changes all set, be sure to commit the migration file in the same commit as your changes to `models.py` diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..adbe62a --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,75 @@ +# 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/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..30cbc57 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,91 @@ +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..bb975ed --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${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/3b8a4c7fbcc2_report_reviewed_column_added.py b/migrations/versions/3b8a4c7fbcc2_report_reviewed_column_added.py new file mode 100644 index 0000000..b44b510 --- /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, default=False)) + + +def downgrade(): + op.drop_column('report', 'reviewed') + diff --git a/migrations/versions/4f95c173f1d9_initial_schema.py b/migrations/versions/4f95c173f1d9_initial_schema.py new file mode 100644 index 0000000..d2da803 --- /dev/null +++ b/migrations/versions/4f95c173f1d9_initial_schema.py @@ -0,0 +1,57 @@ +"""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/migrations/versions/76898f8ac346_add_reports.py b/migrations/versions/76898f8ac346_add_reports.py new file mode 100644 index 0000000..e5ec032 --- /dev/null +++ b/migrations/versions/76898f8ac346_add_reports.py @@ -0,0 +1,34 @@ +"""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, autoincrement=True), + sa.Column('quote_id', sa.Integer(), nullable=False), + 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=False, default=False)) + + +def downgrade(): + op.drop_column('quote', 'hidden') + op.drop_table('report') + diff --git a/quotefault/__init__.py b/quotefault/__init__.py index 00d4ef9..edf2e46 100644 --- a/quotefault/__init__.py +++ b/quotefault/__init__.py @@ -1,11 +1,15 @@ -# import all relevant packages +""" +File name: __init__.py +Author: Nicholas Mercadante +Contributors: Devin Matte, Max Meinhold, Joe Abbate +""" import os 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 import Flask, render_template, request, flash, session, make_response, abort, redirect +from flask_migrate import Migrate from flask_pyoidc.flask_pyoidc import OIDCAuthentication from flask_sqlalchemy import SQLAlchemy from sqlalchemy import func @@ -16,8 +20,11 @@ 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)) # Disable SSL certificate verification warning requests.packages.urllib3.disable_warnings() @@ -28,69 +35,38 @@ issuer=app.config['OIDC_ISSUER'], client_registration_info=app.config['OIDC_CLIENT_CONFIG']) -# 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 - - -# 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() - +from .ldap import get_all_members, is_member_of_group +from .mail import send_quote_notification_email, send_report_email +from .models import Quote, Vote, Report def get_metadata(): + """ + Provide metadata to page + UUID, UID, Git Version, Plug, and admin permissions obtained + """ uuid = str(session["userinfo"].get("sub", "")) uid = str(session["userinfo"].get("preferred_username", "")) - version = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']).decode('utf-8').rstrip() + version = subprocess.check_output( + ['git', 'rev-parse', '--short', 'HEAD'] + ).decode('utf-8').rstrip() plug = request.cookies.get('plug') 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() + """ + Root Website, presents submission page + """ metadata = get_metadata() all_members = get_all_members() return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members) @@ -99,6 +75,9 @@ def main(): @app.route('/settings', methods=['GET']) @auth.oidc_auth def settings(): + """ + Presents settings page + """ metadata = get_metadata() return render_template('bootstrap/settings.html', metadata=metadata) @@ -106,6 +85,9 @@ def settings(): @app.route('/vote', methods=['POST']) @auth.oidc_auth def make_vote(): + """ + Called by JS to add vote to DB + """ # submitter will grab UN from OIDC when linked to it submitter = session['userinfo'].get('preferred_username', '') @@ -118,18 +100,20 @@ def make_vote(): db.session.add(vote) db.session.commit() return '200' - elif existing_vote.direction != direction: + if existing_vote.direction != direction: existing_vote.direction = direction existing_vote.updated_time = datetime.now() db.session.commit() return '200' - else: - return '201' + return '201' @app.route('/settings', methods=['POST']) @auth.oidc_auth def update_settings(): + """ + Update settings from settings page + """ metadata = get_metadata() resp = make_response(render_template('bootstrap/settings.html', metadata=metadata)) if request.form['plug'] == "off": @@ -143,8 +127,12 @@ def update_settings(): @app.route('/submit', methods=['POST']) @auth.oidc_auth def submit(): - # submitter will grab UN from OIDC when linked to it + """ + Submit quote from main page, add to DB + """ + #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'] @@ -154,16 +142,24 @@ def submit(): speaker = request.form['nameString'] # check for quote duplicate - quoteCheck = Quote.query.filter(Quote.quote == quote).first() + quote_check = Quote.query.filter(Quote.quote == quote).first() # checks for empty quote or submitter if quote == '' or speaker == '': flash('Empty quote or speaker field, try again!', 'error') - return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members), 200 - elif submitter == speaker: + return render_template( + 'bootstrap/main.html', + metadata=metadata, + all_members=all_members + ), 200 + if submitter == speaker: flash('You can\'t quote yourself! Come on', 'error') - return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members), 200 - elif quoteCheck is None: # no duplicate quotes, proceed with submission + return render_template( + 'bootstrap/main.html', + metadata=metadata, + all_members=all_members + ), 200 + if quote_check is None: # no duplicate quotes, proceed with submission # create a row for the Quote table new_quote = Quote(submitter=submitter, quote=quote, speaker=speaker) db.session.add(new_quote) @@ -172,37 +168,56 @@ 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(speaker) # create a message to flash for successful submission flash('Submission Successful!') # return something to complete submission - return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members), 200 - else: # duplicate quote found, bounce the user back to square one - flash('Quote already submitted!', 'warning') - return render_template('bootstrap/main.html', metadata=metadata, all_members=all_members), 200 - + return render_template( + 'bootstrap/main.html', + metadata=metadata, + all_members=all_members + ), 200 + # duplicate quote found, bounce the user back to square one + flash('Quote already submitted!', 'warning') + 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(): + """ + Show submitted quotes, only showing first 20 initially + """ 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() - - #tie any votes the user has made to their uid - user_votes = db.session.query(Vote).filter(Vote.voter == metadata['submitter']).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 = Vote.query.filter(Vote.voter == metadata['uid']).all() return render_template( 'bootstrap/storage.html', quotes=quotes, @@ -215,28 +230,163 @@ def get(): @app.route('/additional', methods=['GET']) @auth.oidc_auth def additional_quotes(): + """ + Show beyond the first 20 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, user_votes=user_votes ) + +@app.route('/report/', methods=['POST']) +@auth.oidc_auth +def submit_report(quote_id): + """ + Report a quote and notify EBoard/RTP + """ + metadata = get_metadata() + existing_report = Report.query.filter(Report.reporter==metadata['uid'], + Report.quote_id==quote_id).first() + if existing_report: + flash("You already submitted a report for this Quote!") + return redirect('/storage') + new_report = Report(quote_id, metadata['uid'], "Report Reason Not Given") + db.session.add(new_report) + db.session.commit() + if app.config['MAIL_SERVER'] != '': + send_report_email( metadata['uid'], Quote.query.get(quote_id) ) + flash("Report Successful!") + return redirect('/storage') + +@app.route('/review', methods=['GET']) +@auth.oidc_auth +def review(): + """ + Presents page for admins to review reports + and either hide or keep quote + """ + metadata = get_metadata() + if metadata['is_admin']: + # Get all outstanding reports + reports = Report.query.filter(Report.reviewed == False).all() + + return render_template( + 'bootstrap/admin.html', + reports=reports, + metadata=metadata + ) + abort(403) + +@app.route('/review//', methods=['POST']) +@auth.oidc_auth +def review_submit(report_id, result): + """ + Called when Admin decides on a quote being hidden or kept + """ + metadata = get_metadata() + report = Report.query.get(report_id) + if not metadata['is_admin']: + abort(403) + if not report: + abort(404) + if report.reviewed: + abort(404) + if result == "keep": + report.reviewed = True + db.session.commit() + flash("Report Completed: Quote Kept") + elif result == "hide": + report.quote.hidden = True + report.reviewed = True + db.session.commit() + flash("Report Completed: Quote Hidden") + else: + abort(400) + return redirect('/review') + + +@app.route('/hide/', methods=['POST']) +@auth.oidc_auth +def hide(quote_id): + """ + Hides a quote + """ + metadata = get_metadata() + quote = Quote.query.get(quote_id) + if not (metadata['uid'] == quote.submitter or metadata['uid'] == quote.speaker or metadata['is_admin']): + abort(403) + if not quote: + abort(404) + quote.hidden = True + db.session.commit() + flash("Quote Hidden!") + return redirect('/storage') + + +@app.route('/unhide/', methods=['POST']) +@auth.oidc_auth +def unhide(quote_id): + """ + Gives admins power to unhide a hidden quote + """ + metadata = get_metadata() + quote = Quote.query.get(quote_id) + if not metadata['is_admin']: + abort(403) + if not quote: + abort(404) + quote.hidden = False + db.session.commit() + flash("Quote Unhidden!") + return redirect('/hidden') + + +@app.route('/hidden', methods=['GET']) +@auth.oidc_auth +def hidden(): + """ + Presents hidden quotes to qualified users + Users who submitted a hidden quote or were quoted in a hidden quote will see those only + Admins see all hidden quotes + """ + metadata = get_metadata() + if metadata['is_admin']: + quotes = get_quote_query(include_hidden=True).filter( Quote.hidden ).all() + else: + quotes = get_quote_query(include_hidden=True).filter( + (Quote.speaker == metadata['uid']) | (Quote.submitter == metadata['uid'] ) + ).filter( Quote.hidden ).all() + return render_template( + 'bootstrap/hidden.html', + quotes=quotes, + metadata=metadata + ) + +@app.errorhandler(403) +def forbidden(e): + return render_template('bootstrap/403.html', metadata=get_metadata()), 403 + +@app.errorhandler(404) +def forbidden(e): + return render_template('bootstrap/404.html', metadata=get_metadata()), 404 + +@app.errorhandler(400) +def forbidden(e): + return render_template('bootstrap/400.html', metadata=get_metadata()), 400 + +@app.errorhandler(409) +def forbidden(e): + return render_template('bootstrap/409.html', metadata=get_metadata()), 409 + diff --git a/quotefault/ldap.py b/quotefault/ldap.py index f1301bc..e1eb623 100644 --- a/quotefault/ldap.py +++ b/quotefault/ldap.py @@ -1,20 +1,37 @@ +""" +File name: ldap.py +Author: Nicholas Mercadante +""" from functools import lru_cache -from quotefault import _ldap, app +from csh_ldap import CSHLDAP +from quotefault import app +# Create CSHLDAP connection +_ldap = CSHLDAP(app.config["LDAP_BIND_DN"], + app.config["LDAP_BIND_PW"]) @lru_cache(maxsize=8192) def get_all_members(): + """ + Get all CSH Members + """ return [{"uid": member.get("uid")[0], "cn": member.get("cn")[0]} for member in _ldap.get_group('member').get_members()] @lru_cache(maxsize=8192) def ldap_get_member(username): + """ + Receive specific member's information based on UID + """ return _ldap.get_member(username, uid=True) @app.context_processor def utility_processor(): + """ + Get a members actual name based on uid + """ def get_display_name(username): try: member = ldap_get_member(username) @@ -22,3 +39,15 @@ 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: + """ + Determine if user is member of LDAP group + """ + 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 + diff --git a/quotefault/mail.py b/quotefault/mail.py index c36d1c2..fde3b75 100644 --- a/quotefault/mail.py +++ b/quotefault/mail.py @@ -1,15 +1,39 @@ -import smtplib -import os +""" +File name: mail.py +Author: Nicholas Mercadante +Contributors: Joe Abbate +""" from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +import smtplib +from flask import render_template +from flask_mail import Mail, Message +from quotefault import app + +mail_client = Mail(app) -def send_email(app, toaddr, subject, body): - fromaddr = "quotefault@csh.rit.edu" +def send_report_email(reporter, quote): + """ + Send email to eboard/rtp for a new report + """ + 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(toaddr, subject, body): + """ + Helper function for quote notification email + """ + fromaddr = app.config['MAIL_USERNAME'] msg = MIMEMultipart() msg['From'] = fromaddr msg['To'] = toaddr msg['Subject'] = subject - body = body msg.attach(MIMEText(body, 'plain')) server = smtplib.SMTP(app.config['MAIL_SERVER'], 25) server.starttls() @@ -18,10 +42,13 @@ 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) +def send_quote_notification_email(user): + """ + Send user email when they are quoted + """ + toaddr = f"{user}@csh.rit.edu" 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(toaddr, subject, body) + diff --git a/quotefault/models.py b/quotefault/models.py new file mode 100644 index 0000000..448c2f7 --- /dev/null +++ b/quotefault/models.py @@ -0,0 +1,90 @@ +""" +Defines the application's database models +""" +import binascii +from datetime import datetime +import os +from sqlalchemy import UniqueConstraint +from quotefault import db + +# create the quote table with all relevant columns +class Quote(db.Model): + """ + Quote table in SQL + """ + __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') + reports = db.relationship('Report', back_populates='quote') + + # 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): + """ + Vote table in SQL + """ + __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, back_populates='votes') + 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): + """ + APIKey table in SQL + """ + __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)) + 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 + +class Report(db.Model): + """ + Report table in SQL + """ + __tablename__ = 'report' + id = db.Column(db.Integer, primary_key=True) + 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=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 + diff --git a/quotefault/templates/bootstrap/400.html b/quotefault/templates/bootstrap/400.html new file mode 100644 index 0000000..84e845d --- /dev/null +++ b/quotefault/templates/bootstrap/400.html @@ -0,0 +1,21 @@ +{% extends "bootstrap/base.html" %} + +{% block styles %} + +{% endblock %} + +{% block body %} +
+

400 - Bad Request

+
+
+

You input data that was invalid! If this is incorrect, please contact an RTP.

+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} + diff --git a/quotefault/templates/bootstrap/403.html b/quotefault/templates/bootstrap/403.html new file mode 100644 index 0000000..4542890 --- /dev/null +++ b/quotefault/templates/bootstrap/403.html @@ -0,0 +1,21 @@ +{% extends "bootstrap/base.html" %} + +{% block styles %} + +{% endblock %} + +{% block body %} +
+

403 - Forbidden

+
+
+

You are not an admin! If this is incorrect, please contact an RTP.

+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} + diff --git a/quotefault/templates/bootstrap/404.html b/quotefault/templates/bootstrap/404.html new file mode 100644 index 0000000..db3a7b0 --- /dev/null +++ b/quotefault/templates/bootstrap/404.html @@ -0,0 +1,21 @@ +{% extends "bootstrap/base.html" %} + +{% block styles %} + +{% endblock %} + +{% block body %} +
+

404 - Not Found

+
+
+

Something was entered that does not exist. Check your URL! If you have any questions, contact an RTP.

+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} + diff --git a/quotefault/templates/bootstrap/409.html b/quotefault/templates/bootstrap/409.html new file mode 100644 index 0000000..f6ea5a2 --- /dev/null +++ b/quotefault/templates/bootstrap/409.html @@ -0,0 +1,21 @@ +{% extends "bootstrap/base.html" %} + +{% block styles %} + +{% endblock %} + +{% block body %} +
+

409 - Conflict

+
+
+

Your request conflicts the current data within the system. If you believe this is wrong, contact an RTP.

+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} + diff --git a/quotefault/templates/bootstrap/additional_quotes.html b/quotefault/templates/bootstrap/additional_quotes.html index 586fb5a..516343d 100644 --- a/quotefault/templates/bootstrap/additional_quotes.html +++ b/quotefault/templates/bootstrap/additional_quotes.html @@ -24,3 +24,4 @@ {% endfor %} + diff --git a/quotefault/templates/bootstrap/admin.html b/quotefault/templates/bootstrap/admin.html new file mode 100644 index 0000000..fe9903a --- /dev/null +++ b/quotefault/templates/bootstrap/admin.html @@ -0,0 +1,45 @@ +{% 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}} | Reason: {{report.reason}} +
+ +
+ {% endfor %} +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} + diff --git a/quotefault/templates/bootstrap/base.html b/quotefault/templates/bootstrap/base.html index 5c3d877..55b34b0 100644 --- a/quotefault/templates/bootstrap/base.html +++ b/quotefault/templates/bootstrap/base.html @@ -62,6 +62,20 @@ Storage + + {% if metadata['is_admin'] %} + + {% endif %}