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)
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
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, )
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], )
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()
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, )
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)