diff --git a/app/api.py b/app/api.py index 816a1d7..4c68848 100644 --- a/app/api.py +++ b/app/api.py @@ -3,7 +3,7 @@ import time from app import db, cas from app.util import to_json, fail, succ, requires_login -from app.models import User, Person, Group +from app.models import User, Person, Group, PersonPersistent api_bp = Blueprint('api', __name__) @@ -50,7 +50,8 @@ def api_students(): if not criteria['filters'].get('school_code'): criteria['filters']['school_code'] = [] criteria['filters']['school_code'].append('YC') - students = Person.search(criteria) + students = Person.search_respect_privacy_include_persistent(criteria) + return to_json(students) @@ -61,7 +62,8 @@ def api_people(): criteria = request.get_json(force=True) or {} except: criteria = {} - people = Person.search(criteria) + people = Person.search_respect_privacy_include_persistent(criteria) + return to_json(people) diff --git a/app/models.py b/app/models.py index 99e49b1..76fbbb5 100644 --- a/app/models.py +++ b/app/models.py @@ -1,9 +1,11 @@ from app import app, db from app.search import SearchableMixin -from app.util import get_now +from app.util import get_now, PERSISTENT_FIELDS, scrub_hidden_data import jwt import datetime +from copy import copy from sqlalchemy.sql import collate +from sqlalchemy.orm.session import make_transient leaderships = db.Table( @@ -199,7 +201,49 @@ def search(criteria): people = person_query.all() return people + def get_persistent_data(self): + return PersonPersistent.query.filter_by(person_id=self.id).first() + @staticmethod + def search_respect_privacy_include_persistent(criteria): + ''' + Performs a search, including persistent data. + - Persistent data, including privacy and social information, is returned. + - If a person's privacy settings hide a certain field, it will be omitted from the results. + - The returned objects will be expunged, and changes to them will not be committed. + ''' + people = Person.search(criteria) + people_copy = [] + for person in people: + person_copy = copy(person) + make_transient(person_copy) + + persistent_data = person.get_persistent_data() + if persistent_data is not None: + for field in PERSISTENT_FIELDS: + setattr(person_copy, field, getattr(persistent_data, field)) + + scrub_hidden_data(person_copy) + people_copy.append(person_copy) + return people_copy + +class PersonPersistent(db.Model): + __tablename__ = 'person_persistent' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + person_id = db.Column(db.Integer, db.ForeignKey('person.id')) + + # socials + socials_instagram = db.Column(db.String) + socials_snapchat = db.Column(db.String) + + # privacy + privacy_hide_image = db.Column(db.Boolean) + privacy_hide_email = db.Column(db.Boolean) + privacy_hide_room = db.Column(db.Boolean) + privacy_hide_phone = db.Column(db.Boolean) + privacy_hide_address = db.Column(db.Boolean) + privacy_hide_major = db.Column(db.Boolean) + privacy_hide_birthday = db.Column(db.Boolean) class Group(db.Model): __tablename__ = 'group' __searchable__ = ( diff --git a/app/routes.py b/app/routes.py index 7c27af3..b39c9d9 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,7 +1,7 @@ from flask import render_template, make_response, request, redirect, url_for, jsonify, abort, g, session from app import app, db, scraper, cas -from app.models import User, Person, Key -from app.util import requires_login, to_json, get_now, succ, fail +from app.models import User, Person, Key, PersonPersistent +from app.util import requires_login, forbidden_via_api, to_json, get_now, succ, fail, PERSISTENT_FIELDS, is_valid_instagram_username, is_valid_snapchat_username from .cas_validate import validate from sqlalchemy import distinct @@ -25,12 +25,12 @@ def store_user(): token_cookie = request.cookies.get('token') if token_cookie: g.user = User.from_token(token_cookie) - method_used = 'cookie' + g.method_used = 'cookie' authorization = request.headers.get('Authorization') if g.user is None and authorization: token = authorization.split(' ')[-1] g.user = User.from_token(token) - method_used = 'header' + g.method_used = 'header' if g.user is None and cas.username: g.user = User.query.get(cas.username) if not g.user: @@ -41,14 +41,14 @@ def store_user(): registered_on=timestamp, admin=is_first_user) db.session.add(g.user) - method_used = 'CAS' + g.method_used = 'CAS' if g.user: g.person = Person.query.filter_by(netid=g.user.id, school_code='YC').first() if g.user.banned or (not g.person and not g.user.admin): # TODO: give a more graceful error than just a 403 abort(403) try: - print(f'Authorized request by {g.person.first_name} {g.person.last_name} with {method_used} authentication.') + print(f'Authorized request by {g.person.first_name} {g.person.last_name} with {g.method_used} authentication.') except AttributeError: print('Could not render name.') g.user.last_seen = timestamp @@ -237,6 +237,7 @@ def hide_me(): @app.route('/keys', methods=['GET']) @requires_login +@forbidden_via_api def get_keys(): keys = Key.query.filter_by(user_id=g.user.id, deleted=False).all() @@ -245,6 +246,7 @@ def get_keys(): @app.route('/keys', methods=['POST']) @requires_login +@forbidden_via_api def create_key(): payload = request.get_json() key = g.user.create_key(payload['description']) @@ -252,17 +254,9 @@ def create_key(): db.session.commit() return to_json(key) - -""" -@app.route('/keys/', methods=['POST']) -@requires_login -def update_key(key_id): - pass -""" - - @app.route('/keys/', methods=['DELETE']) @requires_login +@forbidden_via_api def delete_key(key_id): key = Key.query.get(key_id) if key.user_id != g.user.id: @@ -271,6 +265,53 @@ def delete_key(key_id): db.session.commit() return succ('Key deleted.') +@app.route('/edit', methods=['GET']) +@requires_login +def edit(): + if g.person is None: + return fail('Could not find person in the database.', 403) + person_persistent = g.person.get_persistent_data() + + if person_persistent is None: + return render_template('edit.html') + return render_template( + 'edit.html', + socials_instagram=person_persistent.socials_instagram, + socials_snapchat=person_persistent.socials_snapchat, + privacy_hide_image=person_persistent.privacy_hide_image, + privacy_hide_email=person_persistent.privacy_hide_email, + privacy_hide_room=person_persistent.privacy_hide_room, + privacy_hide_phone=person_persistent.privacy_hide_phone, + privacy_hide_address=person_persistent.privacy_hide_address, + privacy_hide_major=person_persistent.privacy_hide_major, + privacy_hide_birthday=person_persistent.privacy_hide_birthday + ) + +@app.route('/edit', methods=['POST']) +@requires_login +@forbidden_via_api +def edit_post(): + payload = request.get_json() + socials_instagram = payload["socials_instagram"] if hasattr(payload, "socials_instagram") else None + socials_snapchat = payload["socials_snapchat"] if hasattr(payload, "socials_snapchat") else None + if (socials_instagram is not None) and (len(socials_instagram) != 0) and (not is_valid_instagram_username(payload["socials_instagram"])): + return fail('Invalid Instagram username.', 400) + if (socials_snapchat is not None) and (len(socials_snapchat) != 0) and (not is_valid_snapchat_username(payload["socials_snapchat"])): + return fail('Invalid Snapchat username.', 400) + + if g.person is None: + return fail('Could not find person in the database.', 403) + person_persistent = g.person.get_persistent_data() + if person_persistent is None: + person_persistent = PersonPersistent(person_id=g.person.id) + db.session.add(person_persistent) + + for key in PERSISTENT_FIELDS: + if key in payload: + setattr(person_persistent, key, payload[key]) + + db.session.commit() + return succ('Person edited.') @app.route('/authorize', methods=['POST']) def api_authorize_cas(): diff --git a/app/scraper/__init__.py b/app/scraper/__init__.py index 74f245e..f677c87 100644 --- a/app/scraper/__init__.py +++ b/app/scraper/__init__.py @@ -1,5 +1,5 @@ from app import app, db, celery, elasticsearch -from app.models import leaderships, Group, Person +from app.models import leaderships, Group, Person, PersonPersistent from app.mail import send_scraper_report from app.scraper import sources @@ -106,8 +106,11 @@ def scrape(caches_active, face_book_cookie, people_search_session_cookie, csrf_t Group.query.delete() Person.query.delete() - elasticsearch.indices.delete(index=Person.__tablename__) - elasticsearch.indices.create(index=Person.__tablename__) + + if elasticsearch is not None: + elasticsearch.indices.delete(index=Person.__tablename__) + elasticsearch.indices.create(index=Person.__tablename__) + num_inserted = 0 for person_dict in people: if not person_dict.get('netid'): diff --git a/app/static/css/edit.css b/app/static/css/edit.css new file mode 100644 index 0000000..895b0d8 --- /dev/null +++ b/app/static/css/edit.css @@ -0,0 +1,21 @@ +.form-group { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 20px; + margin-bottom: 10px; +} + +h3 { + margin-bottom: 20px; + font-size: 20px; +} + +#error { + color: #FF0000; +} + +#success { + color: #00FF00; +} diff --git a/app/static/js/edit.js b/app/static/js/edit.js new file mode 100644 index 0000000..f8200f4 --- /dev/null +++ b/app/static/js/edit.js @@ -0,0 +1,47 @@ +const FIELDS = [ + 'socials_instagram', + // 'socials_snapchat', + 'privacy_hide_image', + 'privacy_hide_email', + 'privacy_hide_room', + 'privacy_hide_phone', + 'privacy_hide_address', + 'privacy_hide_major', + 'privacy_hide_birthday' +]; + +const submit = document.getElementById('submit'); +const error = document.getElementById('error'); +const success = document.getElementById('success'); + +submit.onclick = async (e) => { + error.innerText = ''; + success.innerText = ''; + + const data = {}; + for(const field of FIELDS) { + const elem = document.getElementById(field); + if(elem.type === 'checkbox') data[field] = elem.checked; + else if(elem.type === 'text') data[field] = elem.value; + } + let response; + try { + response = await fetch('/edit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }) + } catch(e) { + console.error(e); + error.innerText = 'An unexpected error has occurred.'; + } + if(response.status === 200) { + success.innerText = 'Your profile has been updated'; + } else { + console.error(await response.text()); + const json = await response.json(); + error.innerText = json.message; + } +} diff --git a/app/static/js/index.js b/app/static/js/index.js index 66babc9..3b98777 100644 --- a/app/static/js/index.js +++ b/app/static/js/index.js @@ -505,6 +505,8 @@ function loadNextPage() { addPill(pills, "major", "Major", "book", person); addPill(pills, "birthday", "Birthday", "birthday-cake", person); addPill(pills, "address", "Address", "home", person); + addPill(pills, "socials_instagram", "Instagram", "instagram", person); + addPill(pills, "socials_snapchat", "Snapchat", "snapchat", person); // Append the pills container to the person container personContainer.appendChild(pills); diff --git a/app/templates/base.html b/app/templates/base.html index 8d4f7f8..c75079b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,6 +21,7 @@ + @@ -39,6 +40,7 @@