Пример #1
0
class Tier(db.Model):
    """This model defines tier of support that commercial users can sign up to."""
    __tablename__ = 'tier'

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.Unicode, nullable=False)
    short_desc = db.Column(db.UnicodeText)
    long_desc = db.Column(db.UnicodeText)
    price = db.Column(db.Numeric(11, 2), nullable=False)  # per month

    # Users can sign up only to available tiers on their own. If tier is not
    # available, it should be hidden from the website.
    available = db.Column(db.Boolean, nullable=False, default=False)

    # Primary tiers are shown first on the signup page. Secondary plans (along
    # with repeating primary plans) are listed on the "view all tiers" page
    # that lists all available tiers.
    primary = db.Column(db.Boolean, nullable=False, default=False)

    users = db.relationship("User", backref='tier', lazy="dynamic")

    def __unicode__(self):
        return "%s (#%s)" % (self.name, self.id)

    def __str__(self):
        return unicode(self).encode('utf-8')

    @classmethod
    def create(cls, **kwargs):
        new_tier = cls(
            name=kwargs.pop('name'),
            short_desc=kwargs.pop('short_desc', None),
            long_desc=kwargs.pop('long_desc', None),
            price=kwargs.pop('price'),
            available=kwargs.pop('available', False),
            primary=kwargs.pop('primary', False),
        )
        db.session.add(new_tier)
        db.session.commit()
        return new_tier

    @classmethod
    def get(cls, **kwargs):
        return cls.query.filter_by(**kwargs).first()

    @classmethod
    def get_available(cls, sort=False, sort_desc=False):
        """Returns list of tiers that are available for sign up.

        You can also sort returned list by price of the tier.
        """
        query = cls.query.filter(cls.available == True)
        if sort:
            query = query.order_by(cls.price.desc()) if sort_desc else \
                    query.order_by(cls.price.asc())
        return query.all()

    def get_featured_users(self, **kwargs):
        return User.get_featured(tier_id=self.id, **kwargs)
Пример #2
0
class TokenLog(db.Model):
    """TokenLog class is used for logging changes to access tokens."""
    __tablename__ = 'token_log'

    token_value = db.Column(db.String,
                            db.ForeignKey('token.value'),
                            primary_key=True)
    timestamp = db.Column(db.DateTime(timezone=True),
                          primary_key=True,
                          default=datetime.utcnow)
    action = db.Column(
        db.Enum(ACTION_DEACTIVATE,
                ACTION_CREATE,
                name='token_log_action_types'),
        primary_key=True,
    )
    user_id = db.Column(
        db.Integer,
        db.ForeignKey('user.id', ondelete="SET NULL", onupdate="CASCADE"))

    @classmethod
    def create_record(cls, access_token, action):
        user_id = current_user.id if current_user.is_authenticated() else None
        new_record = cls(
            token_value=access_token,
            action=action,
            user_id=user_id,
        )
        db.session.add(new_record)
        db.session.commit()
        return new_record

    @classmethod
    def list(cls, limit=None, offset=None):
        query = cls.query.order_by(cls.timestamp.desc())
        count = query.count()  # Total count should be calculated before limits
        if limit is not None:
            query = query.limit(limit)
        if offset is not None:
            query = query.offset(offset)
        return query.all(), count
Пример #3
0
class Donation(db.Model):
    __tablename__ = 'donation'

    id = db.Column(db.Integer, primary_key=True)

    # Personal details
    first_name = db.Column(db.Unicode, nullable=False)
    last_name = db.Column(db.Unicode, nullable=False)
    email = db.Column(db.Unicode, nullable=False)
    editor_name = db.Column(db.Unicode)  # MusicBrainz username
    can_contact = db.Column(db.Boolean, nullable=False, default=True)
    anonymous = db.Column(db.Boolean, nullable=False, default=False)
    address_street = db.Column(db.Unicode)
    address_city = db.Column(db.Unicode)
    address_state = db.Column(db.Unicode)
    address_postcode = db.Column(db.Unicode)
    address_country = db.Column(db.Unicode)

    # Transaction details
    payment_date = db.Column(db.DateTime(timezone=True),
                             default=datetime.utcnow)
    payment_method = db.Column(
        db.Enum(PAYMENT_METHOD_STRIPE,
                PAYMENT_METHOD_PAYPAL,
                PAYMENT_METHOD_WEPAY,
                PAYMENT_METHOD_BITCOIN,
                PAYMENT_METHOD_CHECK,
                name='payment_method_types'))
    transaction_id = db.Column(db.Unicode)
    amount = db.Column(db.Numeric(11, 2), nullable=False)
    fee = db.Column(db.Numeric(11, 2))
    memo = db.Column(db.Unicode)

    def __unicode__(self):
        return 'Donation #%s' % self.id

    @classmethod
    def get_by_transaction_id(cls, transaction_id):
        return cls.query.filter_by(transaction_id=str(transaction_id)).first()

    @staticmethod
    def get_nag_days(editor):
        """

        Returns:
            Two values. First one indicates if editor should be nagged:
            -1 = unknown person, 0 = no need to nag, 1 = should be nagged.
            Second is...
        """
        days_per_dollar = 7.5
        result = db.session.execute(
            "SELECT ((amount + COALESCE(fee, 0)) * :days_per_dollar) - "
            "((extract(epoch from now()) - extract(epoch from payment_date)) / 86400) as nag "
            "FROM donation "
            "WHERE lower(editor_name) = lower(:editor) "
            "ORDER BY nag DESC "
            "LIMIT 1", {
                'editor': editor,
                'days_per_dollar': days_per_dollar
            }).fetchone()

        if result is None:
            return -1, 0
        elif result[0] >= 0:
            return 0, result[0]
        else:
            return 1, result[0]

    @classmethod
    def get_recent_donations(cls, limit=None, offset=None):
        """Getter for most recent donations.

        Args:
            limit: Maximum number of donations to be returned.
            offset: Offset of the result.

        Returns:
            Tuple with two items. First is total number if donations. Second
            is a list of donations sorted by payment_date with a specified offset.
        """
        query = cls.query.order_by(cls.payment_date.desc())
        count = query.count()  # Total count should be calculated before limits
        if limit is not None:
            query = query.limit(limit)
        if offset is not None:
            query = query.offset(offset)
        return count, query.all()

    @classmethod
    def get_biggest_donations(cls, limit=None, offset=None):
        """Getter for biggest donations.

        Donations from the same person are grouped.

        Args:
            limit: Maximum number of donations to be returned.
            offset: Offset of the result.

        Returns:
            Tuple with two items. First is total number if donations. Second
            is a list of donations sorted by amount with a specified offset.
        """
        query = db.session.query(
            cls.first_name.label("first_name"),
            cls.last_name.label("last_name"),
            cls.editor_name.label("editor_name"),
            func.max(cls.payment_date).label("payment_date"),
            func.sum(cls.amount).label("amount"),
            func.sum(cls.fee).label("fee"),
        )
        query = query.filter(cls.anonymous == False)
        query = query.group_by(cls.first_name, cls.last_name, cls.editor_name)
        query = query.order_by(desc("amount"))
        count = query.count()  # Total count should be calculated before limits
        if limit is not None:
            query = query.limit(limit)
        if offset is not None:
            query = query.offset(offset)
        return count, query.all()

    @classmethod
    def process_paypal_ipn(cls, form):
        """Processor for PayPal IPNs (Instant Payment Notifications).

        Should be used only after IPN request is verified. See PayPal documentation for
        more info about the process.

        Args:
            form: The form parameters from IPN request that contains IPN variables.
                See https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNandPDTVariables/
                for more info about them.
        """
        logging.debug('Processing PayPal IPN...')

        # Only processing completed donations
        if form['payment_status'] != 'Completed':
            logging.info('PayPal: Payment not completed. Status: "%s".',
                         form['payment_status'])
            return

        # We shouldn't process transactions to address for payments
        # TODO: Clarify what this address is
        if form['business'] == current_app.config['PAYPAL_BUSINESS']:
            logging.info('PayPal: Recieved payment to address for payments.')
            return

        if form['receiver_email'] != current_app.config['PAYPAL_PRIMARY_EMAIL']:
            logging.warning('PayPal: Not primary email. Got "%s".',
                            form['receiver_email'])
            return
        if float(form['mc_gross']) < 0.50:
            # Tiny donation
            logging.info('PayPal: Tiny donation ($%s).', form['mc_gross'])
            return

        # Checking that txn_id has not been previously processed
        if cls.get_by_transaction_id(form['txn_id']) is not None:
            logging.info('PayPal: Transaction ID %s has been used before.',
                         form['txn_id'])
            return

        new_donation = cls(
            first_name=form['first_name'],
            last_name=form['last_name'],
            email=form['payer_email'],
            editor_name=form.get('custom'),
            address_street=form.get('address_street'),
            address_city=form.get('address_city'),
            address_state=form.get('address_state'),
            address_postcode=form.get('address_zip'),
            address_country=form.get('address_country'),
            amount=float(form['mc_gross']) - float(form['mc_fee']),
            fee=float(form['mc_fee']),
            transaction_id=form['txn_id'],
            payment_method=PAYMENT_METHOD_PAYPAL,
        )

        if 'option_name1' in form and 'option_name2' in form:
            if (form['option_name1'] == 'anonymous' and form['option_selection1'] == 'yes') or \
                    (form['option_name2'] == 'anonymous' and form['option_selection2'] == 'yes') or \
                            form['option_name2'] == 'yes':
                new_donation.anonymous = True
            if (form['option_name1'] == 'contact' and form['option_selection1'] == 'yes') or \
                    (form['option_name2'] == 'contact' and form['option_selection2'] == 'yes') or \
                            form['option_name2'] == 'yes':
                new_donation.can_contact = True

        db.session.add(new_donation)
        db.session.commit()
        logging.info('PayPal: Payment added. ID: %s.', new_donation.id)

        send_receipt(
            new_donation.email,
            new_donation.payment_date,
            new_donation.amount,
            '%s %s' % (new_donation.first_name, new_donation.last_name),
            new_donation.editor_name,
        )

    @classmethod
    def verify_and_log_wepay_checkout(cls, checkout_id, editor, anonymous,
                                      can_contact):
        logging.debug('Processing WePay checkout...')

        # Looking up updated information about the object
        wepay = WePay(production=current_app.config['PAYMENT_PRODUCTION'],
                      access_token=current_app.config['WEPAY_ACCESS_TOKEN'])
        details = wepay.call('/checkout', {'checkout_id': checkout_id})

        if 'error' in details:
            logging.warning('WePay: Error: %s', details['error_description'])
            return False

        if 'gross' not in details:
            logging.warning('WePay: The total dollar amount paid is missing')
            return False

        if details['gross'] < 0.50:
            # Tiny donation
            logging.info('WePay: Tiny donation ($%s).', details['gross'])
            return True

        if details['state'] in ['settled', 'captured']:
            # Payment has been received

            # Checking that txn_id has not been previously processed
            if cls.get_by_transaction_id(details['checkout_id']) is not None:
                logging.info('WePay: Transaction ID %s has been used before.',
                             details['checkout_id'])
                return

            new_donation = cls(
                first_name=details['payer_name'],
                last_name='',
                email=details['payer_email'],
                editor_name=editor,
                can_contact=can_contact,
                anonymous=anonymous,
                amount=details['gross'] - details['fee'],
                fee=details['fee'],
                transaction_id=checkout_id,
                payment_method=PAYMENT_METHOD_WEPAY,
            )

            if 'shipping_address' in details:
                address = details['shipping_address']
                new_donation.address_street = "%s\n%s" % (address['address1'],
                                                          address['address2'])
                new_donation.address_city = address['city']
                if 'state' in address:  # US address
                    new_donation.address_state = address['state']
                else:
                    new_donation.address_state = address['region']
                if 'zip' in address:  # US address
                    new_donation.address_postcode = address['zip']
                else:
                    new_donation.address_postcode = address['postcode']

            db.session.add(new_donation)
            db.session.commit()
            logging.info('WePay: Payment added. ID: %s.', new_donation.id)

            send_receipt(
                new_donation.email,
                new_donation.payment_date,
                new_donation.amount,
                '%s %s' % (new_donation.first_name, new_donation.last_name),
                new_donation.editor_name,
            )

        elif details['state'] in ['authorized', 'reserved']:
            # Payment is pending
            logging.info('WePay: Payment is pending. State: "%s".',
                         details['state'])
            pass

        elif details['state'] in [
                'expired', 'cancelled', 'failed', 'refunded', 'chargeback'
        ]:
            # Payment has failed
            logging.warning('WePay: Payment has failed. State: "%s".',
                            details['state'])
            pass

        else:
            # Unknown status
            logging.warning('WePay: Unknown status.')
            return False

        return True

    @classmethod
    def log_stripe_charge(cls, charge):
        """Log successful Stripe charge.

        Args:
            charge: The charge object from Stripe. More information about it is
                available at https://stripe.com/docs/api/python#charge_object.
        """
        logging.debug('Processing Stripe charge...')

        bt = stripe.BalanceTransaction.retrieve(charge.balance_transaction)

        new_donation = cls(
            first_name=charge.source.name,
            last_name='',
            amount=bt.net / 100,  # cents should be converted
            fee=bt.fee / 100,  # cents should be converted
            transaction_id=charge.id,
            payment_method=PAYMENT_METHOD_STRIPE,
            address_street=charge.source.address_line1,
            address_city=charge.source.address_city,
            address_state=charge.source.address_state,
            address_postcode=charge.source.address_zip,
            address_country=charge.source.address_country,
            email=charge.metadata.email,
            can_contact=charge.metadata.can_contact == u'True',
            anonymous=charge.metadata.anonymous == u'True',
        )

        if 'editor' in charge.metadata:
            new_donation.editor_name = charge.metadata.editor

        db.session.add(new_donation)
        db.session.commit()
        logging.info('Stripe: Payment added. ID: %s.', new_donation.id)

        send_receipt(
            new_donation.email,
            new_donation.payment_date,
            new_donation.amount,
            new_donation.first_name,  # Last name is not used with Stripe
            new_donation.editor_name,
        )
Пример #4
0
class User(db.Model, UserMixin):
    """User model is used for users of MetaBrainz services like Live Data Feed.

    Users are either commercial or non-commercial (see `is_commercial`). Their
    access to the API is determined by their `state` (active, pending, waiting,
    or rejected). All non-commercial users have active state by default, but
    commercial users need to be approved by one of the admins first.
    """
    __tablename__ = 'user'

    # Common columns used by both commercial and non-commercial users:
    id = db.Column(db.Integer, primary_key=True)
    is_commercial = db.Column(db.Boolean, nullable=False)
    musicbrainz_id = db.Column(
        db.Unicode, unique=True)  # MusicBrainz account that manages this user
    created = db.Column(db.DateTime(timezone=True), default=datetime.utcnow)
    state = db.Column(postgres.ENUM(STATE_ACTIVE,
                                    STATE_PENDING,
                                    STATE_WAITING,
                                    STATE_REJECTED,
                                    STATE_LIMITED,
                                    name='state_types'),
                      nullable=False)
    contact_name = db.Column(db.Unicode, nullable=False)
    contact_email = db.Column(db.Unicode, nullable=False)
    data_usage_desc = db.Column(db.UnicodeText)

    # Columns specific to commercial users:
    org_name = db.Column(db.Unicode)
    org_logo_url = db.Column(db.Unicode)
    website_url = db.Column(db.Unicode)
    api_url = db.Column(db.Unicode)
    org_desc = db.Column(db.UnicodeText)
    address_street = db.Column(db.Unicode)
    address_city = db.Column(db.Unicode)
    address_state = db.Column(db.Unicode)
    address_postcode = db.Column(db.Unicode)
    address_country = db.Column(db.Unicode)
    tier_id = db.Column(
        db.Integer,
        db.ForeignKey('tier.id', ondelete="SET NULL", onupdate="CASCADE"))
    amount_pledged = db.Column(db.Numeric(11, 2))

    # Administrative columns:
    good_standing = db.Column(db.Boolean, nullable=False, default=True)
    in_deadbeat_club = db.Column(db.Boolean, nullable=False, default=False)
    featured = db.Column(db.Boolean, nullable=False, default=False)

    tokens = db.relationship("Token", backref="owner", lazy="dynamic")
    token_log_records = db.relationship("TokenLog",
                                        backref="user",
                                        lazy="dynamic")

    def __unicode__(self):
        if self.is_commercial:
            return "%s (#%s)" % (self.org_name, self.id)
        else:
            if self.musicbrainz_id:
                return "#%s (MBID: %s)" % (self.id, self.musicbrainz_id)
            else:
                return str(self.id)

    @property
    def token(self):
        return Token.get(owner_id=self.id, is_active=True)

    @classmethod
    def add(cls, **kwargs):
        new_user = cls(
            is_commercial=kwargs.pop('is_commercial'),
            musicbrainz_id=kwargs.pop('musicbrainz_id'),
            contact_name=kwargs.pop('contact_name'),
            contact_email=kwargs.pop('contact_email'),
            data_usage_desc=kwargs.pop('data_usage_desc'),
            org_desc=kwargs.pop('org_desc', None),
            org_name=kwargs.pop('org_name', None),
            org_logo_url=kwargs.pop('org_logo_url', None),
            website_url=kwargs.pop('website_url', None),
            api_url=kwargs.pop('api_url', None),
            address_street=kwargs.pop('address_street', None),
            address_city=kwargs.pop('address_city', None),
            address_state=kwargs.pop('address_state', None),
            address_postcode=kwargs.pop('address_postcode', None),
            address_country=kwargs.pop('address_country', None),
            tier_id=kwargs.pop('tier_id', None),
            amount_pledged=kwargs.pop('amount_pledged', None),
        )
        new_user.state = STATE_ACTIVE if not new_user.is_commercial else STATE_PENDING
        if kwargs:
            raise TypeError('Unexpected **kwargs: %r' % kwargs)
        db.session.add(new_user)
        db.session.commit()

        if new_user.is_commercial:
            send_user_signup_notification(new_user)

        return new_user

    @classmethod
    def get(cls, **kwargs):
        return cls.query.filter_by(**kwargs).first()

    @classmethod
    def get_all(cls, **kwargs):
        return cls.query.filter_by(**kwargs).all()

    @classmethod
    def get_all_commercial(cls, limit=None, offset=None):
        query = cls.query.filter(cls.is_commercial == True).order_by(
            cls.org_name)
        count = query.count()  # Total count should be calculated before limits
        if limit is not None:
            query = query.limit(limit)
        if offset is not None:
            query = query.offset(offset)
        return query.all(), count

    @classmethod
    def get_featured(cls, limit=None, **kwargs):
        """Get list of featured users which is randomly sorted.

        Args:
            limit: Max number of users to return.
            in_deadbeat_club: Returns only users from deadbeat club if set to True.
            with_logos: True if need only users with logo URLs specified, False if
                only users without logo URLs, None if it's irrelevant.
            tier_id: Returns only users from tier with a specified ID.

        Returns:
            List of users according to filters described above.
        """
        query = cls.query.filter(cls.featured == True)
        query = query.filter(
            cls.in_deadbeat_club == kwargs.pop('in_deadbeat_club', False))
        with_logos = kwargs.pop('with_logos', None)
        if with_logos:
            query = query.filter(cls.org_logo_url != None)
        tier_id = kwargs.pop('tier_id', None)
        if tier_id:
            query = query.filter(cls.tier_id == tier_id)
        if kwargs:
            raise TypeError('Unexpected **kwargs: %r' % kwargs)
        return query.order_by(func.random()).limit(limit).all()

    @classmethod
    def search(cls, value):
        """Search users by their musicbrainz_id, org_name, contact_name,
        or contact_email.
        """
        query = cls.query.filter(
            or_(
                cls.musicbrainz_id.ilike('%' + value + '%'),
                cls.org_name.ilike('%' + value + '%'),
                cls.contact_name.ilike('%' + value + '%'),
                cls.contact_email.ilike('%' + value + '%'),
            ))
        return query.limit(20).all()

    def generate_token(self):
        """Generates new access token for this user."""
        if self.state == STATE_ACTIVE:
            return Token.generate_token(self.id)
        else:
            raise InactiveUserException(
                "Can't generate token for inactive user.")

    def update(self, **kwargs):
        contact_name = kwargs.pop('contact_name')
        if contact_name is not None:
            self.contact_name = contact_name
        contact_email = kwargs.pop('contact_email')
        if contact_email is not None:
            self.contact_email = contact_email
        if kwargs:
            raise TypeError('Unexpected **kwargs: %r' % kwargs)
        db.session.commit()

    def set_state(self, state):
        old_state = self.state
        self.state = state
        db.session.commit()
        if old_state != self.state:
            # TODO: Send additional info about new state.
            state_name = "ACTIVE" if self.state == STATE_ACTIVE else \
                         "REJECTED" if self.state == STATE_REJECTED else \
                         "PENDING" if self.state == STATE_PENDING else \
                         "WAITING" if self.state == STATE_WAITING else \
                         "LIMITED" if self.state == STATE_LIMITED else \
                         self.state
            send_mail(
                subject="[MetaBrainz] Your account has been updated",
                text=
                'State of your MetaBrainz account has been changed to "%s".' %
                state_name,
                recipients=[self.contact_email],
            )
Пример #5
0
class AccessLog(db.Model):
    """Access log is used for tracking requests to the API.

    Each request needs to be logged. Logging is done to keep track of number of
    requests in a fixed time frame. If there is an unusual number of requests
    being made from different IP addresses in this time frame, action is taken.
    See implementation of this model for more details.
    """
    __tablename__ = 'access_log'

    token = db.Column(db.String,
                      db.ForeignKey('token.value'),
                      primary_key=True)
    timestamp = db.Column(db.DateTime(timezone=True),
                          primary_key=True,
                          default=datetime.utcnow)
    ip_address = db.Column(postgres.INET)

    @classmethod
    def create_record(cls, access_token, ip_address):
        """Creates new access log record with a current timestamp.

        It also checks if `DIFFERENT_IP_LIMIT` is exceeded within current time
        and `CLEANUP_RANGE_MINUTES`, alerts admins if that's the case.

        Args:
            access_token: Access token used to access the API.
            ip_address: IP access used to access the API.

        Returns:
            New access log record.
        """
        new_record = cls(
            token=access_token,
            ip_address=ip_address,
        )
        db.session.add(new_record)
        db.session.commit()

        # Checking if HOURLY_ALERT_THRESHOLD is exceeded
        count = cls.query \
            .distinct(cls.ip_address) \
            .filter(cls.timestamp > datetime.now(pytz.utc) - timedelta(minutes=CLEANUP_RANGE_MINUTES),
                    cls.token == access_token) \
            .count()
        if count > DIFFERENT_IP_LIMIT:
            msg = ("Hourly access threshold exceeded for token %s\n\n"
                   "This token has been used from %s different IP "
                   "addresses during the last %s minutes.") % \
                  (access_token, count, CLEANUP_RANGE_MINUTES)
            logging.info(msg)
            # Checking if notification for admins about this token abuse has
            # been sent in the last hour. This info is kept in cache.
            key = "alert_sent_%s" % access_token
            if not cache.get(key):
                send_mail(
                    subject="[MetaBrainz] Hourly access threshold exceeded",
                    recipients=current_app.config['NOTIFICATION_RECIPIENTS'],
                    text=msg,
                )
                cache.set(key, True, 3600)  # 1 hour

        return new_record

    @classmethod
    def remove_old_ip_addr_records(cls):
        cls.query. \
            filter(cls.timestamp < datetime.now(pytz.utc) - timedelta(minutes=CLEANUP_RANGE_MINUTES)). \
            update({cls.ip_address: None})
        db.session.commit()

    @classmethod
    def get_hourly_usage(cls, user_id=None):
        """Get information about API usage.

        Args:
            user_id: User ID that can be specified to get stats only for that account.

        Returns:
            List of <datetime, request count> tuples for every hour.
        """
        if not user_id:
            rows = db.engine.execute(
                'SELECT max("timestamp") as ts, count(*) '
                'FROM access_log '
                'GROUP BY extract(year from "timestamp"), extract(month from "timestamp"), '
                '         extract(day from "timestamp"), trunc(extract(hour from "timestamp")) '
                'ORDER BY ts')
        else:
            rows = db.engine.execute(
                'SELECT max(access_log."timestamp") as ts, count(access_log.*) '
                'FROM access_log '
                'JOIN token ON access_log.token = token.value '
                'JOIN "user" ON token.owner_id = "user".id '
                'WHERE "user".id = %s '
                'GROUP BY extract(year from "timestamp"), extract(month from "timestamp"), '
                '         extract(day from "timestamp"), trunc(extract(hour from "timestamp")) '
                'ORDER BY ts', (user_id, ))
        return [(r[0].replace(
            minute=0,
            second=0,
            microsecond=0,
            tzinfo=None,
        ), r[1]) for r in rows]

    @classmethod
    def active_user_count(cls):
        """Returns number of different users whose access has been logged in
        the last 24 hours.
        """
        return cls.query.join(Token).join(User) \
            .filter(cls.timestamp > datetime.now() - timedelta(days=1)) \
            .distinct(User.id).count()

    @classmethod
    def top_downloaders(cls, limit=None):
        """Generates list of most active users in the last 24 hours.

        Args:
            limit: Max number of items to return.

        Returns:
            List of <User, request count> pairs
        """
        query = db.session.query(User).join(Token).join(AccessLog) \
            .filter(cls.timestamp > datetime.now() - timedelta(days=1)) \
            .add_columns(func.count("AccessLog.*").label("count")).group_by(User.id) \
            .order_by("count DESC")
        if limit:
            query = query.limit(limit)
        return query.all()
Пример #6
0
class Payment(db.Model):
    __tablename__ = 'payment'

    id = db.Column(db.Integer, primary_key=True)

    # Personal details
    first_name = db.Column(db.Unicode, nullable=False)
    last_name = db.Column(db.Unicode, nullable=False)
    is_donation = db.Column(db.Boolean, nullable=False)
    email = db.Column(db.Unicode, nullable=False)
    address_street = db.Column(db.Unicode)
    address_city = db.Column(db.Unicode)
    address_state = db.Column(db.Unicode)
    address_postcode = db.Column(db.Unicode)
    address_country = db.Column(db.Unicode)

    # Donation-specific columns
    editor_name = db.Column(db.Unicode)  # MusicBrainz username
    can_contact = db.Column(db.Boolean)
    anonymous = db.Column(db.Boolean)

    # Organization-specific columns
    invoice_number = db.Column(db.Integer)

    # Transaction details
    payment_date = db.Column(db.DateTime(timezone=True),
                             default=datetime.utcnow)
    payment_method = db.Column(
        db.Enum(
            PAYMENT_METHOD_STRIPE,
            PAYMENT_METHOD_PAYPAL,
            PAYMENT_METHOD_WEPAY,  # legacy
            PAYMENT_METHOD_CHECK,
            name='payment_method_types'))
    transaction_id = db.Column(db.Unicode)
    amount = db.Column(db.Numeric(11, 2), nullable=False)
    fee = db.Column(db.Numeric(11, 2))
    currency = db.Column(
        db.Enum(Currency.US_Dollar.value,
                Currency.Euro.value,
                name='payment_currency'),
        nullable=False,
        default='usd',
    )
    memo = db.Column(db.Unicode)

    def __str__(self):
        return 'Payment #%s' % self.id

    @classmethod
    def get_by_transaction_id(cls, transaction_id):
        return cls.query.filter_by(transaction_id=str(transaction_id)).first()

    @staticmethod
    def get_nag_days(editor):
        """

        Returns:
            Two values. First one indicates if editor should be nagged:
            -1 = unknown person, 0 = no need to nag, 1 = should be nagged.
            Second is...
        """
        days_per_dollar = 7.5
        result = db.session.execute(
            "SELECT ((amount + COALESCE(fee, 0)) * :days_per_dollar) - "
            "((extract(epoch from now()) - extract(epoch from payment_date)) / 86400) as nag "
            "FROM payment "
            "WHERE lower(editor_name) = lower(:editor) "
            "ORDER BY nag DESC "
            "LIMIT 1", {
                'editor': editor,
                'days_per_dollar': days_per_dollar
            }).fetchone()

        if result is None:
            return -1, 0
        elif result[0] >= 0:
            return 0, result[0]
        else:
            return 1, result[0]

    @classmethod
    def get_recent_donations(cls, limit=None, offset=None):
        """Getter for most recent donations.

        Args:
            limit: Maximum number of donations to be returned.
            offset: Offset of the result.

        Returns:
            Tuple with two items. First is total number if donations. Second
            is a list of donations sorted by payment_date with a specified offset.
        """
        query = cls.query.order_by(cls.payment_date.desc())
        query = query.filter(cls.is_donation == True)
        count = query.count()  # Total count should be calculated before limits
        if limit is not None:
            query = query.limit(limit)
        if offset is not None:
            query = query.offset(offset)
        return count, query.all()

    @classmethod
    def get_biggest_donations(cls, limit=None, offset=None):
        """Getter for biggest donations.

        Donations from the same person are grouped.

        Args:
            limit: Maximum number of donations to be returned.
            offset: Offset of the result.

        Returns:
            Tuple with two items. First is total number if donations. Second
            is a list of donations sorted by amount with a specified offset.
        """
        query = db.session.query(
            cls.first_name.label("first_name"),
            cls.last_name.label("last_name"),
            cls.editor_name.label("editor_name"),
            func.max(cls.payment_date).label("payment_date"),
            func.sum(cls.amount).label("amount"),
            func.sum(cls.fee).label("fee"),
        )
        query = query.filter(cls.is_donation == True)
        query = query.filter(cls.anonymous == False)
        query = query.group_by(cls.first_name, cls.last_name, cls.editor_name)
        query = query.order_by(desc("amount"))
        count = query.count()  # Total count should be calculated before limits
        if limit is not None:
            query = query.limit(limit)
        if offset is not None:
            query = query.offset(offset)
        return count, query.all()

    @classmethod
    def process_paypal_ipn(cls, form):
        """Processor for PayPal IPNs (Instant Payment Notifications).

        Should be used only after IPN request is verified. See PayPal documentation for
        more info about the process.

        Args:
            form: The form parameters from IPN request that contains IPN variables.
                See https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNandPDTVariables/
                for more info about them.
        """
        logging.debug('Processing PayPal IPN...')

        # Only processing completed donations
        if form['payment_status'] != 'Completed':
            # TODO(roman): Convert to regular `logging.info` call when such detailed logs
            # are no longer necessary to capture.
            get_sentry_client().captureMessage(
                "PayPal: Payment is not completed",
                level=logging.INFO,
                extra={"ipn_content": form})
            return

        account_ids = current_app.config[
            'PAYPAL_ACCOUNT_IDS']  # "currency => account" mapping

        if form['mc_currency'].lower() not in SUPPORTED_CURRENCIES:
            logging.warning("PayPal IPN: Unsupported currency",
                            extra={"ipn_content": form})
            return

        # We shouldn't process transactions to address for payments
        # TODO: Clarify what this address is
        if form['business'] == current_app.config['PAYPAL_BUSINESS']:
            logging.info('PayPal: Received payment to address for payments.',
                         extra={"ipn_content": form})
            return

        # Checking if payment was sent to the right account depending on the currency
        if form['mc_currency'].upper() in account_ids:
            receiver_email_expected = current_app.config['PAYPAL_ACCOUNT_IDS'][
                form['mc_currency'].upper()]
            if receiver_email_expected != form['receiver_email']:
                logging.warning("Received payment to an unexpected address",
                                extra={
                                    "currency": form['mc_currency'],
                                    "received_to_address":
                                    form['receiver_email'],
                                    "expected_address":
                                    receiver_email_expected,
                                    "ipn_content": form,
                                })
        if form['receiver_email'] not in account_ids.values():
            logging.warning('PayPal: Unexpected receiver email',
                            extra={
                                "received_to_address": form['receiver_email'],
                                "ipn_content": form,
                            })

        if float(form['mc_gross']) < 0.50:
            # Tiny donation
            logging.info('PayPal: Tiny donation', extra={"ipn_content": form})
            return

        # Checking that txn_id has not been previously processed
        if cls.get_by_transaction_id(form['txn_id']) is not None:
            logging.info('PayPal: Transaction ID has been used before',
                         extra={
                             "transaction_id": form['txn_id'],
                             "ipn_content": form,
                         })
            return

        options = cls._extract_paypal_ipn_options(form)

        # If donation option (whether it is donation or not) is not specified, assuming
        # that this payment is donation. This is done to support old IPN messages.
        is_donation = options.get("is_donation", "yes") == "yes"

        new_payment = cls(
            is_donation=is_donation,
            first_name=form['first_name'],
            last_name=form['last_name'],
            email=form['payer_email'],
            address_street=form.get('address_street'),
            address_city=form.get('address_city'),
            address_state=form.get('address_state'),
            address_postcode=form.get('address_zip'),
            address_country=form.get('address_country'),
            amount=float(form['mc_gross']) - float(form['mc_fee']),
            fee=float(form['mc_fee']),
            transaction_id=form['txn_id'],
            currency=form['mc_currency'].lower(),
            payment_method=PAYMENT_METHOD_PAYPAL,
        )

        if is_donation:
            new_payment.editor_name = form.get('custom')

            anonymous_opt = options.get("anonymous")
            if anonymous_opt is None:
                logging.warning("PayPal: Anonymity option is missing",
                                extra={"ipn_content": form})
            else:
                new_payment.anonymous = anonymous_opt == "yes"

            contact_opt = options.get("contact")
            if contact_opt is None:
                logging.warning("PayPal: Contact option is missing",
                                extra={"ipn_content": form})
            else:
                new_payment.can_contact = contact_opt == "yes"

        else:
            invoice_num_opt = options.get("invoice_number")
            if invoice_num_opt is None:
                logging.warning(
                    "PayPal: Invoice number is missing from org payment",
                    extra={"ipn_content": form})
            else:
                new_payment.invoice_number = int(invoice_num_opt)

        db.session.add(new_payment)
        db.session.commit()
        logging.info('PayPal: Payment added. ID: %s.', new_payment.id)

        send_receipt(
            email=new_payment.email,
            date=new_payment.payment_date,
            amount=new_payment.amount,
            name='%s %s' % (new_payment.first_name, new_payment.last_name),
            is_donation=new_payment.is_donation,
            editor_name=new_payment.editor_name,
        )

    @staticmethod
    def _extract_paypal_ipn_options(form: dict) -> dict:
        """Extracts all options from a PayPal IPN.
        
        This is necessary because the order or numbering of options might not
        what you expect it to be.
         
        Returns:
            Dictionary that maps options (by name) to their values.
        """
        options = {}
        current_number = 1  # option numbering starts from 1, not 0
        while True:
            current_key = "option_name" + str(current_number)
            current_val = "option_selection" + str(current_number)
            if current_key not in form:
                break
            if current_val not in form:
                logging.warning(
                    "PayPal: Value for option `{name}` is missing".format(
                        name=current_key),
                    extra={"ipn_content": form})
            options[form[current_key]] = form[current_val]
            current_number += 1
        return options

    @classmethod
    def log_stripe_charge(cls, charge):
        """Log successful Stripe charge.

        Args:
            charge: The charge object from Stripe. More information about it is
                available at https://stripe.com/docs/api/python#charge_object.
        """
        logging.debug('Processing Stripe charge...')

        bt = stripe.BalanceTransaction.retrieve(charge.balance_transaction)

        if bt.currency.lower() not in SUPPORTED_CURRENCIES:
            logging.warning("Unsupported currency: ", bt.currency)
            return

        new_donation = cls(
            first_name=charge.source.name,
            last_name='',
            amount=bt.net / 100,  # cents should be converted
            fee=bt.fee / 100,  # cents should be converted
            currency=bt.currency.lower(),
            transaction_id=charge.id,
            payment_method=PAYMENT_METHOD_STRIPE,
            is_donation=charge.metadata.is_donation,
            email=charge.metadata.email,
            address_street=charge.source.address_line1,
            address_city=charge.source.address_city,
            address_state=charge.source.address_state,
            address_postcode=charge.source.address_zip,
            address_country=charge.source.address_country,
        )

        if charge.metadata.is_donation == "True":
            new_donation.is_donation = 1
        if charge.metadata.is_donation == "False":
            new_donation.is_donation = 0

        if new_donation.is_donation:
            if charge.metadata.can_contact:
                new_donation.can_contact = 1
            else:
                new_donation.can_contact = 0
            if charge.metadata.anonymous:
                new_donation.anonymous = 1
            else:
                new_donation.anonymous = 0

            if 'editor' in charge.metadata:
                new_donation.editor_name = charge.metadata.editor
        else:  # Organization payment
            new_donation.invoice_number = charge.metadata.invoice_number

        db.session.add(new_donation)
        try:
            db.session.commit()
            logging.info('Stripe: Payment added. ID: %s.', new_donation.id)
        except TypeError as err:
            logging.error("Cannot record payment: ", err)

        send_receipt(
            email=new_donation.email,
            date=new_donation.payment_date,
            amount=new_donation.amount,
            name=new_donation.first_name,  # Last name is not used with Stripe
            is_donation=new_donation.is_donation,
            editor_name=new_donation.editor_name,
        )
Пример #7
0
class Token(db.Model):
    __tablename__ = 'token'

    value = db.Column(db.String, primary_key=True)
    is_active = db.Column(db.Boolean, nullable=False, default=True)
    owner_id = db.Column(
        db.Integer,
        db.ForeignKey('user.id', ondelete="SET NULL", onupdate="CASCADE"))
    created = db.Column(db.DateTime(timezone=True), default=datetime.utcnow)

    log_records = db.relationship(TokenLog, backref="token", lazy="dynamic")

    @classmethod
    def get(cls, **kwargs):
        return cls.query.filter_by(**kwargs).first()

    @classmethod
    def get_all(cls, **kwargs):
        return cls.query.filter_by(**kwargs).all()

    @classmethod
    def search_by_value(cls, value):
        return cls.query.filter(cls.value.like('%' + value + '%')).all()

    @classmethod
    def generate_token(cls, owner_id):
        """Generates new token for a specified user and revokes all other
        tokens owned by this user.

        Returns:
            Value of the new token.
        """
        if owner_id is not None:
            last_hour_q = cls.query.filter(
                cls.owner_id == owner_id,
                cls.created > datetime.utcnow() - timedelta(hours=1),
            )
            if last_hour_q.count() > 0:
                raise TokenGenerationLimitException(
                    "Can't generate more than one token per hour.")
            cls.revoke_tokens(owner_id)

        new_token = cls(
            value=generate_string(TOKEN_LENGTH),
            owner_id=owner_id,
        )
        db.session.add(new_token)
        db.session.commit()

        TokenLog.create_record(new_token.value, token_log.ACTION_CREATE)

        return new_token.value

    @classmethod
    def revoke_tokens(cls, owner_id):
        """Revokes all tokens owned by a specified user.

        Args:
            owner_id: ID of a user.
        """
        tokens = db.session.query(cls).filter(cls.owner_id == owner_id,
                                              cls.is_active == True)
        for token in tokens:
            token.revoke()

    @classmethod
    def is_valid(cls, token_value):
        """Checks if token exists and is active."""
        token = cls.get(value=token_value)
        return token and token.is_active

    def revoke(self):
        self.is_active = False
        db.session.commit()
        TokenLog.create_record(self.value, token_log.ACTION_DEACTIVATE)