litecord.blueprints: split users bp into user sub-bp

- blueprints.user: add biling bp
 - blueprints.user: add settings bp
 - schema.sql: add user_payment_sources, user_subscriptions,
    user_payments
This commit is contained in:
Luna Mendes 2018-11-14 03:43:40 -03:00
parent 49aa6cd495
commit b9ef4c6d8c
7 changed files with 480 additions and 116 deletions

View File

@ -0,0 +1,2 @@
from .billing import bp as user_billing
from .settings import bp as user_settings

View File

@ -0,0 +1,264 @@
import pprint
import json
from enum import Enum
from quart import Blueprint, jsonify, request, current_app as app
from litecord.auth import token_check
from litecord.schemas import validate
from litecord.blueprints.checks import guild_check
from litecord.storage import timestamp_
from litecord.snowflake import snowflake_datetime, get_snowflake
bp = Blueprint('users_billing', __name__)
class PaymentSource(Enum):
CREDIT = 1
PAYPAL = 2
class SubscriptionStatus:
ACTIVE = 1
CANCELLED = 3
class SubscriptionType:
# unknown
PURCHASE = 1
UPGRADE = 2
class SubscriptionPlan:
CLASSIC = 1
NITRO = 2
class PaymentGateway:
STRIPE = 1
BRAINTREE = 2
class PaymentStatus:
SUCCESS = 1
FAILED = 2
CREATE_SUBSCRIPTION = {
'payment_gateway_plan_id': {'type': 'string'},
'payment_source_id': {'coerce': int}
}
PAYMENT_SOURCE = {
'billing_address': {
'type': 'dict',
'schema': {
'country': {'type': 'string', 'required': True},
'city': {'type': 'string', 'required': True},
'name': {'type': 'string', 'required': True},
'line_1': {'type': 'string', 'required': False},
'line_2': {'type': 'string', 'required': False},
'postal_code': {'type': 'string', 'required': True},
'state': {'type': 'string', 'required': True},
}
},
'payment_gateway': {'type': 'number', 'required': True},
'token': {'type': 'string', 'required': True},
}
async def get_payment_source_ids(user_id: int) -> list:
rows = await app.db.fetch("""
SELECT id
FROM user_payment_sources
WHERE user_id = $1
""", user_id)
return [r['id'] for r in rows]
async def get_payment_ids(user_id: int) -> list:
rows = await app.db.fetch("""
SELECT id
FROM user_payments
WHERE user_id = $1
""", user_id)
return [r['id'] for r in rows]
async def get_subscription_ids(user_id: int) -> list:
rows = await app.db.fetch("""
SELECT id
FROM user_subscriptions
WHERE user_id = $1
""", user_id)
return [r['id'] for r in rows]
async def get_payment_source(user_id: int, source_id: int) -> dict:
"""Get a payment source's information."""
source = {}
source_type = await app.db.fetchval("""
SELECT source_type
FROM user_payment_sources
WHERE id = $1 AND user_id = $2
""", source_id, user_id)
source_type = PaymentSource(source_type)
specific_fields = {
PaymentSource.PAYPAL: ['paypal_email'],
PaymentSource.CREDIT: ['expires_month', 'expires_year',
'brand', 'cc_full']
}[source_type]
fields = ','.join(specific_fields)
extras_row = await app.db.fetchrow(f"""
SELECT {fields}, billing_address, default_, id::text
FROM user_payment_sources
WHERE id = $1
""", source_id)
derow = dict(extras_row)
if source_type == PaymentSource.CREDIT:
derow['last_4'] = derow['cc_full'][-4:]
derow.pop('cc_full')
derow['default'] = derow['default_']
derow.pop('default_')
source = {
'id': str(source_id),
'type': source_type.value,
}
return {**source, **derow}
async def get_subscription(subscription_id: int):
row = await app.db.execute("""
SELECT id::text, source_id::text AS payment_source_id,
payment_gateway, payment_gateway_plan_id,
period_start AS current_period_start,
period_end AS current_period_end,
canceled_at, s_type, status
FROM user_subscriptions
WHERE id = $1
""", subscription_id)
drow = dict(row)
drow['type'] = drow['s_type']
drow.pop('s_type')
to_tstamp = ['current_period_start', 'current_period_end', 'canceled_at']
for field in to_tstamp:
drow[field] = timestamp_(drow[field])
return drow
async def get_payment(payment_id: int):
row = await app.db.execute("""
SELECT id::text, source_id, subscription_id,
amount, amount_refunded, currency,
description, status, tax, tax_inclusive
FROM user_payments
WHERE id = $1
""", payment_id)
drow = dict(row)
drow['created_at'] = snowflake_datetime(int(drow['id']))
return drow
@bp.route('/@me/billing/payment-sources', methods=['GET'])
async def _get_billing_sources():
user_id = await token_check()
source_ids = await get_payment_source_ids(user_id)
res = []
for source_id in source_ids:
source = await get_payment_source(user_id, source_id)
res.append(source)
return jsonify(res)
@bp.route('/@me/billing/subscriptions', methods=['GET'])
async def _get_billing_subscriptions():
user_id = await token_check()
sub_ids = await get_subscription_ids(user_id)
res = []
for sub_id in sub_ids:
res.append(await get_subscription(sub_id))
return jsonify(res)
@bp.route('/@me/billing/payments', methods=['GET'])
async def _get_billing_payments():
user_id = await token_check()
payment_ids = await get_payment_ids(user_id)
res = []
for payment_id in payment_ids:
res.append(await get_payment(payment_id))
return jsonify(res)
@bp.route('/@me/billing/payment-sources', methods=['POST'])
async def _create_payment_source():
user_id = await token_check()
j = validate(await request.get_json(), PAYMENT_SOURCE)
new_source_id = get_snowflake()
await app.db.execute(
"""
INSERT INTO user_payment_sources (id, user_id, source_type,
default_, expires_month, expires_year, brand, cc_full,
billing_address)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
""", new_source_id, user_id, PaymentSource.CREDIT.value,
True, 12, 6969, 'Visa', '4242424242424242',
json.dumps(j['billing_address']))
return jsonify(
await get_payment_source(user_id, new_source_id)
)
@bp.route('/@me/billing/subscriptions', methods=['POST'])
async def _create_subscription():
user_id = await token_check()
j = validate(await request.get_json(), CREATE_SUBSCRIPTION)
@bp.route('/@me/billing/subscriptions/<int:subscription_id>',
methods=['DELETE'])
async def _delete_subscription(subscription_id):
user_id = await token_check()
return '', 204
@bp.route('/@me/billing/subscriptions/<int:subscription_id>',
methods=['PATCH'])
async def _patch_subscription(subscription_id):
"""change a subscription's payment source"""
user_id = await token_check()
j = validate(await request.get_json(), PATCH_SUBSCRIPTION)
# returns subscription object

View File

@ -0,0 +1,127 @@
from quart import Blueprint, jsonify, request, current_app as app
from litecord.auth import token_check
from litecord.schemas import validate, USER_SETTINGS, GUILD_SETTINGS
from litecord.blueprints.checks import guild_check
bp = Blueprint('users_settings', __name__)
@bp.route('/@me/settings', methods=['GET'])
async def get_user_settings():
"""Get the current user's settings."""
user_id = await token_check()
settings = await app.storage.get_user_settings(user_id)
return jsonify(settings)
@bp.route('/@me/settings', methods=['PATCH'])
async def patch_current_settings():
"""Patch the users' current settings.
More information on what settings exist
is at Storage.get_user_settings and the schema.sql file.
"""
user_id = await token_check()
j = validate(await request.get_json(), USER_SETTINGS)
for key in j:
await app.db.execute(f"""
UPDATE user_settings
SET {key}=$1
WHERE id = $2
""", j[key], user_id)
settings = await app.storage.get_user_settings(user_id)
await app.dispatcher.dispatch_user(
user_id, 'USER_SETTINGS_UPDATE', settings)
return jsonify(settings)
@bp.route('/@me/guilds/<int:guild_id>/settings', methods=['PATCH'])
async def patch_guild_settings(guild_id: int):
"""Update the users' guild settings for a given guild.
Guild settings are usually related to notifications.
"""
user_id = await token_check()
await guild_check(user_id, guild_id)
j = validate(await request.get_json(), GUILD_SETTINGS)
# querying the guild settings information before modifying
# will make sure they exist in the table.
await app.storage.get_guild_settings_one(user_id, guild_id)
for field in (k for k in j.keys() if k != 'channel_overrides'):
await app.db.execute(f"""
UPDATE guild_settings
SET {field} = $1
WHERE user_id = $2 AND guild_id = $3
""", j[field], user_id, guild_id)
chan_ids = await app.storage.get_channel_ids(guild_id)
for chandata in j.get('channel_overrides', {}).items():
chan_id, chan_overrides = chandata
chan_id = int(chan_id)
# ignore channels that aren't in the guild.
if chan_id not in chan_ids:
continue
for field in chan_overrides:
await app.db.execute(f"""
INSERT INTO guild_settings_channel_overrides
(user_id, guild_id, channel_id, {field})
VALUES
($1, $2, $3, $4)
ON CONFLICT
ON CONSTRAINT guild_settings_channel_overrides_pkey
DO
UPDATE
SET {field} = $4
WHERE guild_settings_channel_overrides.user_id = $1
AND guild_settings_channel_overrides.guild_id = $2
AND guild_settings_channel_overrides.channel_id = $3
""", user_id, guild_id, chan_id, chan_overrides[field])
settings = await app.storage.get_guild_settings_one(user_id, guild_id)
await app.dispatcher.dispatch_user(
user_id, 'USER_GUILD_SETTINGS_UPDATE', settings)
return jsonify(settings)
@bp.route('/@me/notes/<int:target_id>', methods=['PUT'])
async def put_note(target_id: int):
"""Put a note to a user.
This route is in this blueprint because I consider
notes to be personalized settings, so.
"""
user_id = await token_check()
j = await request.get_json()
note = str(j['note'])
# UPSERTs are beautiful
await app.db.execute("""
INSERT INTO notes (user_id, target_id, note)
VALUES ($1, $2, $3)
ON CONFLICT ON CONSTRAINT notes_pkey
DO UPDATE SET
note = $3
WHERE notes.user_id = $1
AND notes.target_id = $2
""", user_id, target_id, note)
await app.dispatcher.dispatch_user(user_id, 'USER_NOTE_UPDATE', {
'id': str(target_id),
'note': note,
})
return '', 204

View File

@ -242,6 +242,7 @@ async def get_me_guilds():
@bp.route('/@me/guilds/<int:guild_id>', methods=['DELETE'])
async def leave_guild(guild_id: int):
"""Leave a guild."""
user_id = await token_check()
await guild_check(user_id, guild_id)
@ -275,65 +276,6 @@ async def get_connections():
pass
@bp.route('/@me/notes/<int:target_id>', methods=['PUT'])
async def put_note(target_id: int):
"""Put a note to a user."""
user_id = await token_check()
j = await request.get_json()
note = str(j['note'])
# UPSERTs are beautiful
await app.db.execute("""
INSERT INTO notes (user_id, target_id, note)
VALUES ($1, $2, $3)
ON CONFLICT ON CONSTRAINT notes_pkey
DO UPDATE SET
note = $3
WHERE notes.user_id = $1
AND notes.target_id = $2
""", user_id, target_id, note)
await app.dispatcher.dispatch_user(user_id, 'USER_NOTE_UPDATE', {
'id': str(target_id),
'note': note,
})
return '', 204
@bp.route('/@me/settings', methods=['GET'])
async def get_user_settings():
"""Get the current user's settings."""
user_id = await token_check()
settings = await app.storage.get_user_settings(user_id)
return jsonify(settings)
@bp.route('/@me/settings', methods=['PATCH'])
async def patch_current_settings():
"""Patch the users' current settings.
More information on what settings exist
is at Storage.get_user_settings and the schema.sql file.
"""
user_id = await token_check()
j = validate(await request.get_json(), USER_SETTINGS)
for key in j:
await app.db.execute(f"""
UPDATE user_settings
SET {key}=$1
WHERE id = $2
""", j[key], user_id)
settings = await app.storage.get_user_settings(user_id)
await app.dispatcher.dispatch_user(
user_id, 'USER_SETTINGS_UPDATE', settings)
return jsonify(settings)
@bp.route('/@me/consent', methods=['GET', 'POST'])
async def get_consent():
"""Always disable data collection.
@ -420,59 +362,3 @@ async def get_profile(peer_id: int):
'premium_since': peer_premium,
'mutual_guilds': mutual_res,
})
@bp.route('/@me/guilds/<int:guild_id>/settings', methods=['PATCH'])
async def patch_guild_settings(guild_id: int):
"""Update the users' guild settings for a given guild.
Guild settings are usually related to notifications.
"""
user_id = await token_check()
await guild_check(user_id, guild_id)
j = validate(await request.get_json(), GUILD_SETTINGS)
# querying the guild settings information before modifying
# will make sure they exist in the table.
await app.storage.get_guild_settings_one(user_id, guild_id)
for field in (k for k in j.keys() if k != 'channel_overrides'):
await app.db.execute(f"""
UPDATE guild_settings
SET {field} = $1
WHERE user_id = $2 AND guild_id = $3
""", j[field], user_id, guild_id)
chan_ids = await app.storage.get_channel_ids(guild_id)
for chandata in j.get('channel_overrides', {}).items():
chan_id, chan_overrides = chandata
chan_id = int(chan_id)
# ignore channels that aren't in the guild.
if chan_id not in chan_ids:
continue
for field in chan_overrides:
await app.db.execute(f"""
INSERT INTO guild_settings_channel_overrides
(user_id, guild_id, channel_id, {field})
VALUES
($1, $2, $3, $4)
ON CONFLICT
ON CONSTRAINT guild_settings_channel_overrides_pkey
DO
UPDATE
SET {field} = $4
WHERE guild_settings_channel_overrides.user_id = $1
AND guild_settings_channel_overrides.guild_id = $2
AND guild_settings_channel_overrides.channel_id = $3
""", user_id, guild_id, chan_id, chan_overrides[field])
settings = await app.storage.get_guild_settings_one(user_id, guild_id)
await app.dispatcher.dispatch_user(
user_id, 'USER_GUILD_SETTINGS_UPDATE', settings)
return jsonify(settings)

View File

@ -69,7 +69,8 @@ class LitecordValidator(Validator):
def _validate_type_voice_region(self, value: str) -> bool:
# TODO: complete this list
return value.lower() in ('brazil', 'us-east', 'us-west', 'us-south', 'russia')
return value.lower() in ('brazil', 'us-east', 'us-west',
'us-south', 'russia')
def _validate_type_verification_level(self, value: int) -> bool:
return _in_enum(VerificationLevel, value)

7
run.py
View File

@ -28,6 +28,10 @@ from litecord.blueprints.channel import (
channel_messages, channel_reactions, channel_pins
)
from litecord.blueprints.user import (
user_settings, user_billing
)
from litecord.ratelimits.handler import ratelimit_handler
from litecord.ratelimits.main import RatelimitManager
@ -68,7 +72,10 @@ def set_blueprints(app_):
bps = {
gateway: None,
auth: '/auth',
users: '/users',
user_settings: '/users',
user_billing: '/users',
relationships: '/users',
guilds: '/guilds',

View File

@ -156,6 +156,83 @@ CREATE TABLE IF NOT EXISTS user_settings (
);
-- main user billing tables
CREATE TABLE IF NOT EXISTS user_payment_sources (
id bigint PRIMARY KEY,
user_id bigint REFERENCES users (id) NOT NULL,
-- type=1: credit card fields
-- type=2: paypal fields
source_type int,
-- idk lol
invalid bool DEFAULT false,
default_ bool DEFAULT false,
-- credit card info (type 1 only)
expires_month int DEFAULT 12,
expires_year int DEFAULT 3000,
brand text,
cc_full text NOT NULL,
-- paypal info (type 2 only)
paypal_email text DEFAULT 'a@a.com',
-- applies to both
billing_address jsonb DEFAULT '{}'
);
-- actual subscription statuses
CREATE TABLE IF NOT EXISTS user_subscriptions (
id bigint PRIMARY KEY,
source_id bigint REFERENCES user_payment_sources (id) NOT NULL,
user_id bigint REFERENCES users (id) NOT NULL,
-- s_type = 1: purchase
-- s_type = 2: upgrade
s_type int DEFAULT 1,
-- gateway = 1: stripe
-- gateway = 2: braintree
payment_gateway int DEFAULT 0,
payment_gateway_plan_id text,
-- status = 1: active
-- status = 3: cancelled
status int DEFAULT 1,
canceled_at timestamp without time zone default NULL,
-- set by us
period_start timestamp without time zone default (now() at time zone 'utc'),
period_end timestamp without time zone default NULL
);
-- payment logs
CREATE TABLE IF NOT EXISTS user_payments (
id bigint PRIMARY KEY,
source_id bigint REFERENCES user_payment_sources (id),
subscription_id bigint REFERENCES user_subscriptions (id),
user_id bigint REFERENCES users (id),
currency text DEFAULT 'usd',
-- status = 1: success
-- status = 2: failed
status int DEFAULT 1,
-- 499 = 4 dollars 99 cents
amount bigint,
tax int DEFAULT 0,
tax_inclusive BOOL default true,
description text,
amount_refunded int DEFAULT 0
);
-- main user relationships
CREATE TABLE IF NOT EXISTS relationships (
-- the id of who made the relationship