예제 #1
0
    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)
예제 #2
0
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)
예제 #3
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
예제 #4
0
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
예제 #5
0
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'])
예제 #6
0
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))
예제 #7
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
예제 #8
0
    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)
예제 #9
0
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
예제 #10
0
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))
예제 #11
0
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
예제 #12
0
    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
예제 #13
0
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
예제 #14
0
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)
예제 #15
0
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()))
예제 #16
0
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)))
예제 #17
0
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
                   ])
예제 #18
0
    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)
예제 #19
0
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)
예제 #20
0
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())
예제 #21
0
    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
예제 #22
0
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)
예제 #23
0
    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)
예제 #24
0
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)