def patch(self, product_id): """Edit the product.""" product = Product.query.get_or_404(product_id) in_out_schema = ProductSchema(exclude=('id', 'varieties', 'addition_time')) try: updated_product = in_out_schema.load(request.json, instance=product, partial=True) except ValidationError as err: abort(400, {'message': err.messages}) try: db.session.add(updated_product) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) return in_out_schema.jsonify(updated_product)
def get_hour_stats(): """Return the statistics on the volunteering hours.""" if 'start_date' in request.args: try: start_date = datetime.fromisoformat(request.args['start_date']) except ValueError: abort(400, {'message': 'The datetime must be in ISO format with timezone.'}) if start_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: start_date = unix_epoch if 'end_date' in request.args: try: end_date = datetime.fromisoformat(request.args['end_date']) except ValueError: abort(400, {'message': 'The datetime must be in ISO format with timezone.'}) if end_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: end_date = tz_aware_now() student_groups = request.args.getlist('group') project_tag = request.args.get('tag') hours = ( db.session .query(db.func.sum(Application.actual_hours)) .join(Application.activity) .join(Activity.project) .outerjoin(project_tags, Project.id == project_tags.c.project_id) .join(Application.applicant) .filter(Project.lifetime_stage == LifetimeStage.finished) .filter(Activity.start_date > start_date, Activity.end_date < end_date) ) if student_groups: hours = hours.filter(Account.group.in_(student_groups)) if project_tag is not None: hours = hours.filter(project_tags.c.tag_id == project_tag) return jsonify(hours.scalar() or 0)
def delete(self, project_id, activity_id): """Delete the activity.""" project = Project.query.get_or_404(project_id) if not current_user.is_admin and current_user not in project.moderators: abort(403) if project.lifetime_stage not in (LifetimeStage.draft, LifetimeStage.ongoing): abort( 400, { 'message': 'Activities may only be deleted on draft and ongoing projects.' }) activity = Activity.query.get_or_404(activity_id) if activity.internal: abort(404) if activity.project != project: abort(400, { 'message': 'The specified project and activity are unrelated.' }) db.session.delete(activity) try: db.session.commit() remove_notifications({ 'activity_id': activity_id, }) except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) return NO_PAYLOAD
def review_project(project_id): """Review a project in its finalizing stage.""" project = Project.query.get_or_404(project_id) if project.lifetime_stage != LifetimeStage.finalizing: abort(400, {'message': 'Only projects being finalized can be reviewed.'}) if project.review_status != ReviewStatus.pending: abort(400, {'message': 'Can only review projects pending review.'}) allowed_states = { 'approved': ReviewStatus.approved, 'rejected': ReviewStatus.rejected, } if request.json.get('review_status') not in allowed_states: abort(400, {'message': 'Invalid review status specified.'}) project.review_status = allowed_states[request.json['review_status']] if project.review_status == ReviewStatus.approved: project.lifetime_stage = LifetimeStage.finished if 'admin_feedback' in request.json: project.admin_feedback = request.json['admin_feedback'] try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) notify_all(project.moderators, NotificationType.project_review_status_changed, { 'project_id': project.id, }) if project.review_status == ReviewStatus.approved: for activity in project.activities: for application in activity.applications: notify(application.applicant_email, NotificationType.claim_innopoints, { 'project_id': project.id, 'activity_id': activity.id, 'application_id': application.id, }) return NO_PAYLOAD
def reclaim_innopoints(): """Get the innopoints the user had on the old system.""" if 'email' not in request.json or 'password' not in request.json: abort(400, {'message': 'Email/username and password should be specified.'}) conn = sqlite3.connect('db.sqlite3') conn.row_factory = lambda c, r: dict(sqlite3.Row(c, r)) cur = conn.cursor() cur.execute( 'SELECT email, password, points FROM User WHERE email=? OR username=?;', (request.json['email'], ) * 2) user = cur.fetchone() if user is None: abort(403, { 'message': 'This email/username is not associated with any account.' }) if not check_password_hash(user['password'], request.json['password']): abort(403, {'message': 'Incorrect password.'}) if user['points'] != 0: new_transaction = Transaction(account=current_user, change=user['points']) db.session.add(new_transaction) try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) cur.execute('DELETE FROM User WHERE email=?;', (user['email'], )) conn.commit() cur.close() conn.close() return jsonify(user['points'])
def get_innopoint_stats(): """Return the statistics on the amount of innopoints spent.""" if 'start_date' in request.args: try: start_date = datetime.fromisoformat(request.args['start_date']) except ValueError: abort(400, {'message': 'The datetime must be in ISO format with timezone.'}) if start_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: start_date = unix_epoch if 'end_date' in request.args: try: end_date = datetime.fromisoformat(request.args['end_date']) except ValueError: abort(400, {'message': 'The datetime must be in ISO format with timezone.'}) if end_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: end_date = tz_aware_now() student_groups = request.args.getlist('group') innopoints = ( db.session .query(db.func.sum(Transaction.change)) .join(Transaction.account) .join(Transaction.stock_change) .filter(StockChange.time > start_date, StockChange.time < end_date) ) if student_groups: innopoints = innopoints.filter(Account.group.in_(student_groups)) return jsonify(-(innopoints.scalar() or 0))
def delete(self, project_id): """Delete the project entirely.""" project = Project.query.get_or_404(project_id) if not current_user.is_admin and current_user != project.creator: abort(401) if project.lifetime_stage == LifetimeStage.finished: abort(400, {'message': 'Cannot delete a finished project'}) try: db.session.delete(project) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) remove_notifications({ 'project_id': project_id, }) return NO_PAYLOAD
def patch(self, product_id, variety_id): """Update the given variety.""" product = Product.query.get_or_404(product_id) variety = Variety.query.get_or_404(variety_id) if variety.product != product: abort(400, { 'message': 'The specified product and variety are unrelated.' }) in_schema = VarietySchema(exclude=('id', 'product_id', 'stock_changes.variety_id'), context={'update': True}) amount = request.json.pop('amount', None) try: updated_variety = in_schema.load(request.json, instance=variety, partial=True) except ValidationError as err: abort(400, {'message': err.messages}) if amount is not None: diff = amount - variety.amount if diff != 0: stock_change = StockChange( amount=diff, status=StockChangeStatus.carried_out, account=current_user, variety_id=updated_variety.id) db.session.add(stock_change) try: db.session.add(updated_variety) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) out_schema = VarietySchema(exclude=('product_id', 'stock_changes', 'product', 'purchases')) return out_schema.jsonify(updated_variety)
def delete_file(file_id): """Delete the given file by ID.""" file = StaticFile.query.get_or_404(file_id) if file.owner != current_user: abort(401) try: file_manager.delete(str(file_id)) except FileNotFoundError: abort(404, 'File not found on storage') db.session.delete(file) try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) return NO_PAYLOAD
def get_report_info(project_id, activity_id, application_id): """Get the reports from the moderators of the project and an average rating.""" application = Application.query.get_or_404(application_id) activity = Activity.query.get_or_404(activity_id) if activity.internal: abort(404) project = Project.query.get_or_404(project_id) if activity.project != project or application.activity_id != activity.id: abort(400, {'message': 'The specified project, activity and application are unrelated.'}) if current_user not in project.moderators and not current_user.is_admin: abort(401) avg_rating = db.session.query( db.func.round(db.func.avg(VolunteeringReport.rating)) ).join(VolunteeringReport.application).join(Application.activity).join( project_moderation, VolunteeringReport.reporter_email == project_moderation.c.account_email ).filter( Application.applicant_email == application.applicant_email, project_moderation.c.project_id == project_id, ).scalar() or 0 reports = ( VolunteeringReport.query .join(VolunteeringReport.application) .join(Application.activity) .join(project_moderation, VolunteeringReport.reporter_email == project_moderation.c.account_email) .filter( Application.applicant_email == application.applicant_email, project_moderation.c.project_id == project_id, ).all() ) out_schema = VolunteeringReportSchema(only=('content', 'rating', 'time', 'application'), many=True) return jsonify(average_rating=int(avg_rating), reports=out_schema.dump(reports))
def change_telegram(email): """Change a user's Telegram username. If the email is not passed, change own username.""" if email is None: user = current_user else: if not current_user.is_admin and email != current_user.email: abort(401) user = Account.query.get_or_404(email) if 'telegram_username' not in request.json: abort(400, {'message': 'The telegram_username field must be passed.'}) user.telegram_username = request.json['telegram_username'] try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) return NO_PAYLOAD
def delete(self, product_id, variety_id): """Delete the variety.""" product = Product.query.get_or_404(product_id) variety = Variety.query.get_or_404(variety_id) if variety.product != product: abort(400, { 'message': 'The specified product and variety are unrelated.' }) if len(product.varieties) <= 1: abort(400, {'message': 'Cannot leave the product without varieties.'}) try: db.session.delete(variety) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) remove_notifications({ 'variety_id': variety_id, }) return NO_PAYLOAD
def subscribe(): """Adds the user's subscription to push notifications.""" new_subscription = request.json if 'endpoint' not in new_subscription: abort(400, {'message': 'The endpoint must be specified.'}) if ('keys' not in new_subscription or 'auth' not in new_subscription['keys'] or 'p256dh' not in new_subscription['keys']): abort(400, {'message': 'Encryption keys must be specified.'}) try: subscribe_to_push(current_user, new_subscription) except IntegrityError: abort(400, {'message': 'Data integrity violated.'}) push.send( new_subscription, { 'title': 'Test Run', 'body': 'This is how you\'ll see our notifications!', }) return NO_PAYLOAD
def purchase_variety(product_id, variety_id): """Purchase a particular variety of a product.""" purchased_amount = request.json.get('amount') if not isinstance(purchased_amount, int): abort(400, { 'message': 'The purchase amount must be specified as an integer.' }) if purchased_amount <= 0: abort(400, {'message': 'The purchase amount must be positive.'}) product = Product.query.get_or_404(product_id) variety = Variety.query.get_or_404(variety_id) if variety.product != product: abort(400, {'message': 'The specified product and variety are unrelated.'}) log.debug( f'User with balance {current_user.balance} is trying to buy {purchased_amount} of a ' f'product with a price of {product.price}. ' f'Total = {product.price * purchased_amount}') if current_user.balance < product.price * purchased_amount: log.debug('Purchase refused: not enough points') abort(400, {'message': 'Insufficient funds.'}) if purchased_amount > variety.amount: log.debug('Purchase refused: not enough stock') abort(400, {'message': 'Insufficient stock.'}) new_stock_change = StockChange(amount=-purchased_amount, status=StockChangeStatus.pending, account=current_user, variety_id=variety_id) db.session.add(new_stock_change) new_transaction = Transaction(account=current_user, change=-product.price * purchased_amount, stock_change_id=new_stock_change) new_stock_change.transaction = new_transaction db.session.add(new_transaction) try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) log.debug('Purchase successful') admins = Account.query.filter_by(is_admin=True).all() notify_all( admins, NotificationType.new_purchase, { 'account_email': current_user.email, 'product_id': product.id, 'variety_id': variety.id, 'stock_change_id': new_stock_change.id, }) if variety.amount <= 0: notify_all(admins, NotificationType.out_of_stock, { 'product_id': product.id, 'variety_id': variety.id, }) out_schema = StockChangeSchema(exclude=('transaction', 'account', 'account_email', 'product', 'variety')) return out_schema.jsonify(new_stock_change)
def list_products(): """List products available in InnoStore.""" # pylint: disable=invalid-unary-operand-type purchases = (db.session.query( StockChange.variety_id, db.func.sum(StockChange.amount).label('variety_purchases')).join( StockChange.account).filter( StockChange.amount < 0, StockChange.status != StockChangeStatus.rejected, ~Account.is_admin).group_by(StockChange.variety_id).subquery()) color_array = db.func.ARRAY_AGG(Variety.color) default_limit = 24 default_page = 1 default_order_by = 'addition_time' default_order = 'desc' ordering = { ('addition_time', 'asc'): Product.addition_time.asc(), ('addition_time', 'desc'): Product.addition_time.desc(), ('price', 'asc'): Product.price.asc(), ('price', 'desc'): Product.price.desc(), ('purchases', 'asc'): db.nullsfirst(db.asc(-db.func.sum(purchases.c.variety_purchases))), ('purchases', 'desc'): db.nullslast(db.desc(-db.func.sum(purchases.c.variety_purchases))), } try: limit = int(request.args.get('limit', default_limit)) page = int(request.args.get('page', default_page)) order_by = request.args.get('order_by', default_order_by) order = request.args.get('order', default_order) excluded_colors = request.args.getlist('excluded_colors', type=str) min_price = request.args.get('min_price', 0, int) max_price = request.args.get('max_price', type=int) except ValueError: abort(400, {'message': 'Bad query parameters.'}) if max_price is not None and max_price < min_price: abort(400, {'message': 'Maximum price cannot be lower than minimum.'}) if not isinstance(excluded_colors, list) or not \ all(item is None or isinstance(item, str) for item in excluded_colors): abort( 400, { 'message': 'Excluded colors has to be an array of strings and possibly null.' }) if limit < 1 or page < 1: abort(400, {'message': 'Limit and page number must be positive.'}) if (order_by, order) not in ordering: abort(400, {'message': 'Invalid ordering specified.'}) db_query = Product.query if 'q' in request.args: like_query = f'%{request.args["q"]}%' or_condition = or_(Product.name.ilike(like_query), Product.type.ilike(like_query), Product.description.ilike(like_query)) db_query = db_query.filter(or_condition).distinct() if excluded_colors: db_query = db_query.join(Product.varieties) if '\x00' in excluded_colors: db_query = db_query.filter(Variety.color.isnot(None)) excluded_colors.remove('\x00') excluded_colors = [color.lstrip('#') for color in excluded_colors] db_query = (db_query.group_by(Product).having( ~(color_array.cast(db.ARRAY(db.Text)).op('<@')(excluded_colors)))) if min_price > 0: db_query = db_query.filter(Product.price >= min_price) if max_price is not None: db_query = db_query.filter(Product.price <= max_price) count = db.session.query(db_query.subquery()).count() if order_by == 'purchases': if excluded_colors: abort(400, { 'message': 'Ordering by purchases is not allowed when filtering.' }) db_query = (db_query.join(Product.varieties).outerjoin( purchases, Variety.id == purchases.c.variety_id).group_by(Product)) db_query = db_query.order_by(ordering[order_by, order]) db_query = db_query.offset(limit * (page - 1)).limit(limit) schema = ProductSchema(many=True, exclude=('description', 'varieties.stock_changes', 'varieties.product', 'varieties.product_id')) return jsonify(pages=math.ceil(count / limit), data=schema.dump(db_query.all()))
def get_timeline(email): """Get the timeline of the account. If the e-mail is not passed, return own timeline.""" if email is None: user = current_user else: if not current_user.is_admin and email != current_user.email: abort(401) user = Account.query.get_or_404(email) if 'start_date' in request.args: try: start_date = datetime.fromisoformat(request.args['start_date']) except ValueError: abort(400, { 'message': 'The datetime must be in ISO format with timezone.' }) if start_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: start_date = unix_epoch if 'end_date' in request.args: try: end_date = datetime.fromisoformat(request.args['end_date']) except ValueError: abort(400, { 'message': 'The datetime must be in ISO format with timezone.' }) if end_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: end_date = tz_aware_now() # pylint: disable=invalid-unary-operand-type applications = (db.session.query( Application.id.label('application_id'), Application.status.label('application_status') ).add_columns(Application.application_time.label('entry_time')).filter( Application.applicant == user).join(Application.activity).add_columns( Activity.name.label('activity_name'), Activity.id.label('activity_id')).filter(~Activity.internal).join( Activity.project).add_columns( Project.name.label('project_name'), Project.id.label('project_id'), Project.lifetime_stage.label('project_stage')).outerjoin( Application.feedback).add_columns( Feedback.application_id.label('feedback_id'), (Application.actual_hours * Activity.reward_rate).label('reward'))) purchases = (db.session.query( StockChange.id.label('stock_change_id'), StockChange.status.label('stock_change_status'), StockChange.time.label('entry_time')).filter( StockChange.account == user).filter(StockChange.amount < 0).join( StockChange.variety).join(Variety.product).add_columns( Product.id.label('product_id'), Product.name.label('product_name'), Product.type.label('product_type'))) promotions = ( # pylint: disable=unsubscriptable-object db.session.query( Notification.payload['project_id'].label('project_id'), Notification.timestamp.label('entry_time')).filter( Notification.recipient == user, Notification.type == NotificationType.added_as_moderator). join( Project, Project.id == Notification.payload.op('->>')('project_id').cast( db.Integer)).filter( Project.creator != user, Project.lifetime_stage != LifetimeStage.draft).add_columns( Project.name.label('project_name')).outerjoin( Project.activities.and_( Activity.internal, Activity.name == '[[Moderation]]') ).outerjoin( Activity.applications.and_( Application.applicant == user) ).add_columns(Application.id.label('application_id'))) projects = (db.session.query( Project.id.label('project_id'), Project.name.label('project_name'), Project.review_status, Project.creation_time.label('entry_time')).filter( Project.creator == user, Project.lifetime_stage != LifetimeStage.draft)) timeline = subquery_to_events( applications.filter(Application.application_time >= start_date, Application.application_time <= end_date).subquery('application_events'), 'application', ).union( subquery_to_events( purchases.filter( StockChange.time >= start_date, StockChange.time <= end_date).subquery('purchase_events'), 'purchase'), subquery_to_events( promotions.filter(Notification.timestamp >= start_date, Notification.timestamp <= end_date).subquery('promotion_events'), 'promotion'), subquery_to_events( projects.filter( Project.creation_time >= start_date, Project.creation_time <= end_date).subquery('project_events'), 'project')).subquery() ordered_timeline = db.session.query(timeline).order_by( timeline.c.entry_time.desc()) leftover_applications = db.session.query( applications.filter( Application.application_time <= start_date).exists()).scalar() leftover_purchases = db.session.query( purchases.filter(StockChange.time <= start_date).exists()).scalar() leftover_promotions = db.session.query( promotions.filter( Notification.timestamp <= start_date).exists()).scalar() leftover_projects = db.session.query( projects.filter( Project.creation_time <= start_date).exists()).scalar() out_schema = TimelineSchema(many=True) return jsonify(data=out_schema.dump(ordered_timeline.all()), more=any((leftover_applications, leftover_purchases, leftover_promotions, leftover_projects)))
def get_statistics(email): """Get the statistics of the account. If the e-mail is not passed, return own statistics.""" if email is None: user = current_user else: if not current_user.is_admin and email != current_user.email: abort(401) user = Account.query.get_or_404(email) if 'start_date' in request.args: try: start_date = datetime.fromisoformat(request.args['start_date']) except ValueError: abort(400, { 'message': 'The datetime must be in ISO format with timezone.' }) if start_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: start_date = unix_epoch if 'end_date' in request.args: try: end_date = datetime.fromisoformat(request.args['end_date']) except ValueError: abort(400, { 'message': 'The datetime must be in ISO format with timezone.' }) if end_date.tzinfo is None: abort(400, {'message': 'The timezone must be passed.'}) else: end_date = tz_aware_now() volunteering = ( # pylint: disable=invalid-unary-operand-type db.session.query(db.func.sum( Application.actual_hours), db.func.count(Application.id)).filter( Application.applicant == user, Application.status == ApplicationStatus.approved, Application.application_time >= start_date, Application.application_time <= end_date).join( Application.activity ).filter(~Activity.fixed_reward, ~Activity.internal).join( Activity.project ).filter(Project.lifetime_stage == LifetimeStage.finished )).one() rating = (db.session.query(db.func.avg(VolunteeringReport.rating)).join( VolunteeringReport.application).filter( Application.applicant == user, Application.status == ApplicationStatus.approved, Application.application_time >= start_date, Application.application_time <= end_date)).scalar() competences = (db.session.query( db.func.count(feedback_competence.c.feedback_id), feedback_competence.c.competence_id).group_by( feedback_competence.c.competence_id).join( Feedback, feedback_competence.c.feedback_id == Feedback.application_id).join(Feedback.application).filter( Application.applicant == user, Application.application_time >= start_date, Application.application_time <= end_date).join( Competence, feedback_competence.c.competence_id == Competence.id).add_columns(Competence.name).group_by( Competence.name)).all() return jsonify(hours=volunteering[0] or 0, positions=volunteering[1], rating=float(rating or 0), competences=[ dict(zip(('amount', 'id', 'name'), competence)) for competence in competences ])
def patch(self, project_id, activity_id, application_id): """Edit a volunteering report on an application.""" application = Application.query.get_or_404(application_id) activity = Activity.query.get_or_404(activity_id) if activity.internal: abort(404) project = Project.query.get_or_404(project_id) if activity.project != project or application.activity_id != activity.id: abort(400, {'message': 'The specified project, activity and application' ' are unrelated.'}) if current_user not in project.moderators and not current_user.is_admin: abort(403) if project.lifetime_stage != LifetimeStage.finalizing: abort(400, {'message': 'The project must be in the finalizing stage.'}) if application.status != ApplicationStatus.approved: abort(400, {'message': 'Reports may only be modified on approved applications.'}) report = VolunteeringReport.query.filter_by( application_id=application_id, reporter_email=current_user.email ).first_or_404() in_schema = VolunteeringReportSchema(exclude=('time',)) try: updated_report = in_schema.load(request.json, instance=report) except ValidationError as err: abort(400, {'message': err.messages}) try: db.session.add(updated_report) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) out_schema = VolunteeringReportSchema(exclude=('application_id',)) return out_schema.jsonify(updated_report)
def edit_application(project_id, activity_id, application_id): """Change the status or the actual hours of an application.""" application = Application.query.get_or_404(application_id) activity = Activity.query.get_or_404(activity_id) project = Project.query.get_or_404(project_id) if activity.project != project or application.activity_id != activity.id: abort(400, {'message': 'The specified project, activity and application are unrelated.'}) if current_user not in project.moderators and not current_user.is_admin: abort(401) old_status = application.status if 'status' in request.json: if project.lifetime_stage != LifetimeStage.ongoing: abort(400, {'message': 'The status of applications may only be changed ' 'for ongoing projects.'}) try: status = getattr(ApplicationStatus, request.json['status']) except AttributeError: abort(400, {'message': 'A valid application status must be specified.'}) if activity.internal and old_status != status: abort(400, {'message': 'Cannot modify the status of internal applications.'}) application.status = status if 'actual_hours' in request.json: if project.lifetime_stage != LifetimeStage.finalizing: abort(400, {'message': 'The actual hours of applications may only be changed ' 'for finalizing projects.'}) actual_hours = request.json['actual_hours'] if not isinstance(actual_hours, int) or actual_hours < 0: abort(400, {'message': 'Actual hours must be a non-negative integer.'}) if activity.fixed_reward and actual_hours not in (0, 1): abort(400, {'message': 'Working hours on hourly-rate activities' 'may only be set to 0 or 1.'}) if application.status != ApplicationStatus.approved: abort(400, {'message': 'Working hours may only be changed on approved applications.'}) application.actual_hours = actual_hours try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) if application.status != old_status: notify(application.applicant_email, NotificationType.application_status_changed, { 'project_id': project_id, 'activity_id': activity_id, 'application_id': application_id, }) out_schema = ApplicationSchema() return out_schema.jsonify(application)
def list_ongoing_projects(): """List ongoing projects.""" first_activity = db.func.min(Activity.start_date) competence_array = db.func.ARRAY_AGG(activity_competence.c.competence_id) default_order_by = 'creation_time' default_order = 'desc' ordering = { ('creation_time', 'asc'): Project.creation_time.asc(), ('creation_time', 'desc'): Project.creation_time.desc(), ('proximity', 'asc'): first_activity.asc(), ('proximity', 'desc'): first_activity.desc(), } try: spots = request.args.get('spots', 0, type=int) excluded_competences = request.args.getlist('excluded_compentences', type=int) start_date = request.args.get('start_date', type=datetime.fromisoformat) end_date = request.args.get('end_date', type=datetime.fromisoformat) except ValueError: abort(400, {'message': 'Bad query parameters.'}) db_query = Project.query.filter_by(lifetime_stage=LifetimeStage.ongoing) narrowed_activity = None narrowed_subquery = None if spots > 0: narrowed_activity = ( (narrowed_activity or Activity.query) .outerjoin( Activity.applications .and_(Application.status == ApplicationStatus.approved) ) .add_columns( db.func.greatest( Activity.people_required - db.func.count(Application.id), -1 ).label('spots') ).group_by(Activity) ) narrowed_subquery = narrowed_activity.subquery() if excluded_competences: narrowed_activity = ( (narrowed_activity or Activity.query) .join(activity_competence, Activity.id == activity_competence.c.activity_id) .group_by(Activity) .having(~(competence_array.op('<@')(excluded_competences))) ) narrowed_subquery = narrowed_activity.subquery() if narrowed_subquery is not None: db_query = db_query.join(narrowed_subquery, Project.id == narrowed_subquery.c.project_id) else: db_query = db_query.join(Project.activities) if spots > 0: db_query = db_query.filter((narrowed_subquery.c.spots >= spots) | (narrowed_subquery.c.spots == -1)).group_by(Project) if 'q' in request.args: like_query = f'%{request.args["q"]}%' if narrowed_subquery is None: db_query = db_query.filter( or_(Project.name.ilike(like_query), Activity.name.ilike(like_query), Activity.description.ilike(like_query)) ) else: db_query = db_query.filter( or_(Project.name.ilike(like_query), narrowed_subquery.c.name.ilike(like_query), narrowed_subquery.c.description.ilike(like_query)) ) if start_date: if narrowed_subquery is None: last_activity_start = db.func.max(Activity.start_date) else: last_activity_start = db.func.max(narrowed_subquery.c.start_date) db_query = db_query.group_by(Project).having(last_activity_start >= start_date) if end_date: if narrowed_subquery is None: first_activity_end = db.func.min(Activity.end_date) else: first_activity_end = db.func.min(narrowed_subquery.c.end_date) db_query = db_query.group_by(Project).having(first_activity_end <= end_date) order_by = request.args.get('order_by', default_order_by) order = request.args.get('order', default_order) if (order_by, order) not in ordering: abort(400, {'message': 'Invalid ordering specified.'}) if order_by == 'proximity': db_query = db_query.group_by(Project.id) db_query = db_query.order_by(ordering[order_by, order]) conditional_exclude = ['review_status', 'moderators'] if current_user.is_authenticated: conditional_exclude.remove('moderators') if current_user.is_admin: conditional_exclude.remove('review_status') exclude = ['admin_feedback', 'lifetime_stage'] activity_exclude = [f'activities.{field}' for field in ('description', 'telegram_required', 'fixed_reward', 'working_hours', 'reward_rate', 'people_required', 'application_deadline', 'project', 'applications', 'existing_application', 'feedback_questions')] schema = ProjectSchema(many=True, exclude=exclude + activity_exclude + conditional_exclude) return schema.jsonify(db_query.all())
def delete(self, project_id, activity_id, application_id): """Delete a volunteering report on an application.""" application = Application.query.get_or_404(application_id) activity = Activity.query.get_or_404(activity_id) if activity.internal: abort(404) project = Project.query.get_or_404(project_id) if activity.project != project or application.activity_id != activity.id: abort(400, {'message': 'The specified project, activity and application' ' are unrelated.'}) if current_user not in project.moderators and not current_user.is_admin: abort(403) if project.lifetime_stage != LifetimeStage.finalizing: abort(400, {'message': 'The project must be in the finalizing stage.'}) if application.status != ApplicationStatus.approved: abort(400, {'message': 'Reports may only be modified on approved applications.'}) report = VolunteeringReport.query.filter_by( application_id=application_id, reporter_email=current_user.email ).first_or_404() try: db.session.delete(report) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) return NO_PAYLOAD
def leave_feedback(project_id, activity_id, application_id): """Leave feedback on a particular volunteering experience.""" application = Application.query.get_or_404(application_id) activity = Activity.query.get_or_404(activity_id) project = Project.query.get_or_404(project_id) if activity.project != project or application.activity_id != activity.id: abort(400, {'message': 'The specified project, activity and application are unrelated.'}) if application.applicant != current_user: abort(401) if application.feedback is not None: abort(400, {'message': 'Feedback already exists.'}) if application.status != ApplicationStatus.approved: abort(400, {'message': 'Feedback may only be left on approved applications.'}) if project.lifetime_stage != LifetimeStage.finished: abort(400, {'message': 'Feedback may only be left on finished projects.'}) in_schema = FeedbackSchema(exclude=('time',)) try: new_feedback = in_schema.load(request.json) except ValidationError as err: abort(400, {'message': err.messages}) if len(new_feedback.answers) != len(activity.feedback_questions): abort(400, {'message': f'Expected {len(activity.feedback_questions)} answer(s), ' f'found {len(new_feedback.answers)}.'}) new_feedback.application_id = application_id db.session.add(new_feedback) new_transaction = Transaction(account=current_user, change=application.actual_hours * activity.reward_rate, feedback_id=new_feedback) new_feedback.transaction = new_transaction db.session.add(new_transaction) notification = Notification.query.filter( Notification.recipient_email == current_user.email, Notification.type == NotificationType.claim_innopoints, Notification.payload.op('->>')('application_id').cast(db.Integer) == application_id, ).one_or_none() if notification is not None: notification.is_read = True try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) all_feedback_in = all(application.feedback is not None for activity in project.activities for application in activity.applications if not activity.internal) if all_feedback_in: admins = Account.query.filter_by(is_admin=True).all() mods = {*project.moderators, *admins} notify_all(mods, NotificationType.all_feedback_in, { 'project_id': project.id, }) out_schema = FeedbackSchema() return out_schema.jsonify(new_feedback)
def patch(self, project_id, activity_id): """Edit the activity.""" project = Project.query.get_or_404(project_id) if not current_user.is_admin and current_user not in project.moderators: abort(403) if project.lifetime_stage not in (LifetimeStage.draft, LifetimeStage.ongoing): abort( 400, { 'message': 'Activities may only be edited on draft and ongoing projects.' }) activity = Activity.query.get_or_404(activity_id) if activity.internal: abort(404) if activity.project != project: abort(400, { 'message': 'The specified project and activity are unrelated.' }) in_schema = ActivitySchema(exclude=('id', 'project', 'applications', 'internal')) try: with db.session.no_autoflush: updated_activity = in_schema.load(request.json, instance=activity, partial=True) except ValidationError as err: abort(400, {'message': err.messages}) if not updated_activity.draft and not updated_activity.is_complete: abort(400, { 'message': 'Incomplete activities cannot be marked as non-draft.' }) if activity.fixed_reward and activity.working_hours != 1: abort( 400, {'message': 'Cannot set working hours for fixed activities.'}) if not activity.fixed_reward and activity.reward_rate != IPTS_PER_HOUR: abort( 400, { 'message': 'The reward rate for hourly activities may not be changed.' }) with db.session.no_autoflush: if updated_activity.people_required is not None: if updated_activity.accepted_applications > updated_activity.people_required: abort( 400, { 'message': 'Cannot reduce the required people ' 'beyond the amount of existing applications.' }) if updated_activity.draft and updated_activity.applications: abort(400, { 'message': 'Cannot mark as draft, applications exist.' }) for application in updated_activity.applications: if (updated_activity.application_deadline is not None and updated_activity.application_deadline < application.application_time): abort( 400, { 'message': 'Cannot set the deadline earlier ' 'than the existing application' }) if application.status != ApplicationStatus.rejected: application.actual_hours = updated_activity.working_hours try: db.session.add(updated_activity) db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) out_schema = ActivitySchema(exclude=('existing_application', ), context={'user': current_user}) return out_schema.jsonify(updated_activity)
def apply_for_activity(project_id, activity_id): """Apply for volunteering on a particular activity.""" project = Project.query.get_or_404(project_id) activity = Activity.query.get_or_404(activity_id) if activity.internal: abort(404) if activity.project != project: abort(400, {'message': 'The specified project and activity are unrelated.'}) if project.lifetime_stage != LifetimeStage.ongoing: abort(400, {'message': 'Applications may only be placed on ongoing projects.'}) if activity.draft: abort(400, {'message': 'Cannot apply to draft activities.'}) if activity.has_application_from(current_user): abort(400, {'message': 'An application already exists.'}) if activity.telegram_required and not isinstance(request.json.get('telegram'), str): abort(400, {'message': 'This activity requires a Telegram username.'}) if activity.application_deadline is not None and activity.application_deadline < tz_aware_now(): abort(400, {'message': 'The application is past the deadline.'}) new_application = Application(applicant=current_user, activity_id=activity_id, comment=request.json.get('comment'), telegram_username=request.json.get('telegram'), actual_hours=activity.working_hours, status=ApplicationStatus.pending) db.session.add(new_application) try: db.session.commit() except IntegrityError as err: db.session.rollback() log.exception(err) abort(400, {'message': 'Data integrity violated.'}) out_schema = ApplicationSchema(exclude=('applicant', 'actual_hours')) return out_schema.jsonify(new_application)