diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..bfcc3c7 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..29dfcd3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,83 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context +from configures.settings import SQLALCHEMY_DATABASE_URI +from models.database_model import Meta, User + +sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/../") + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +config.set_main_option('sqlalchemy.url', SQLALCHEMY_DATABASE_URI) +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Meta.metadata + + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/bfc7552a8070_message.py b/alembic/versions/bfc7552a8070_message.py new file mode 100644 index 0000000..05cee03 --- /dev/null +++ b/alembic/versions/bfc7552a8070_message.py @@ -0,0 +1,69 @@ +"""message + +Revision ID: bfc7552a8070 +Revises: +Create Date: 2020-05-14 22:10:59.680095 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'bfc7552a8070' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=100), nullable=False), + sa.Column('password_hash', sa.String(length=256), nullable=False, comment='登陆密码 hash 之后的值'), + sa.Column('name', sa.String(length=100), nullable=True), + sa.Column('phone', sa.String(length=20), nullable=True, comment='电话号码'), + sa.Column('avatar', sa.String(length=256), nullable=True, comment='用户头像'), + sa.Column('website', sa.String(length=100), nullable=True, comment='个人网站'), + sa.Column('company', sa.String(length=100), nullable=True, comment='所在公司'), + sa.Column('job', sa.String(length=100), nullable=True, comment='职位'), + sa.Column('location', sa.String(length=100), nullable=True, comment='所在地'), + sa.Column('signature', sa.String(length=256), nullable=True, comment='签名'), + sa.Column('Dribbble', sa.String(length=256), nullable=True, comment='Dribbble'), + sa.Column('Duolingo', sa.String(length=256), nullable=True, comment='Duolingo'), + sa.Column('About_me', sa.String(length=256), nullable=True, comment='About.me'), + sa.Column('Last_me', sa.String(length=256), nullable=True, comment='Last.fm'), + sa.Column('Goodreads', sa.String(length=256), nullable=True, comment='Goodreads'), + sa.Column('GitHub', sa.String(length=256), nullable=True, comment='GitHub'), + sa.Column('PSN_ID', sa.String(length=256), nullable=True, comment='PSN ID'), + sa.Column('Steam_ID', sa.String(length=256), nullable=True, comment='Steam_ID'), + sa.Column('Twitch', sa.String(length=256), nullable=True, comment='Twitch'), + sa.Column('BattleTag', sa.String(length=256), nullable=True, comment='BattleTag'), + sa.Column('Instagram', sa.String(length=256), nullable=True, comment='Instagram'), + sa.Column('Telegram', sa.String(length=256), nullable=True, comment='Telegram'), + sa.Column('Twitter', sa.String(length=256), nullable=True, comment='Twitter'), + sa.Column('BTC_Address', sa.String(length=256), nullable=True, comment='BTC Address'), + sa.Column('Coding_net', sa.String(length=256), nullable=True, comment='Coding.net'), + sa.Column('Personal_Introduction', sa.String(length=256), nullable=True, comment='个人简介'), + sa.Column('state_update_view_permission', sa.Integer(), nullable=True, comment='状态更新查看权限'), + sa.Column('community_rich_rank', sa.Boolean(), nullable=True, comment='社区财富排行榜'), + sa.Column('money', sa.Integer(), nullable=True, comment='余额'), + sa.Column('show_remain_money', sa.Boolean(), nullable=True, comment='是否显示余额'), + sa.Column( + 'use_avatar_for_favicon', sa.Boolean(), nullable=True, comment='使用节点头像作为页面 favicon' + ), + sa.Column('use_high_resolution_avatar', sa.Boolean(), nullable=True, comment='使用高精度头像'), + sa.Column('time_zone', sa.String(length=256), nullable=True, comment='默认使用的时区'), + sa.Column('deleted', sa.Boolean(), nullable=True, comment='该用户是否注销'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user') + # ### end Alembic commands ### diff --git a/models/database_model/__init__.py b/models/database_model/__init__.py index 950aca9..a1719f1 100644 --- a/models/database_model/__init__.py +++ b/models/database_model/__init__.py @@ -1,104 +1,4 @@ -import enum -from datetime import datetime +from models.database_model.base_model import Base, Column, Meta, db +from models.database_model.user_model import User -from flask_sqlalchemy import SQLAlchemy - -from configures.settings import date_format - -db = SQLAlchemy() - -Column = db.Column -relationship = db.relationship - - -class Base(db.Model): - """DataBase Model that Contains CRUD Operations""" - - __abstract__ = True - - @classmethod - def parse_schema(cls, **kwargs): - column_names = cls.__table__.columns.keys() - for key in filter(lambda col_name: col_name not in column_names, kwargs.copy().keys()): - kwargs.pop(key) - - return kwargs - - @classmethod - def create(cls, commit=True, **kwargs): - kwargs = cls.parse_schema(**kwargs) - instance = cls(**kwargs) - - return instance.save(commit) - - @classmethod - def create_many(cls, kwargs_list): - return [cls.create(**kwargs) for kwargs in kwargs_list] - - def update(self, commit=True, **kwargs): - """Update specific fields of a record.""" - kwargs = self.parse_schema(**kwargs) - for attr, value in kwargs.items(): - setattr(self, attr, value) - return commit and self.save() or self - - def delete(self, commit=True): - """Remove the record from the database.""" - db.session.delete(self) - return commit and db.session.commit() - - def save(self, commit=True): - """Save the record.""" - db.session.add(self) - if commit: - db.session.commit() - return self - - def _to_json(self, value): - if hasattr(value, "as_dict"): - return value.as_dict() - elif isinstance(value, datetime): - return value.strftime(date_format) - elif isinstance(value, enum.Enum): - return value.name - elif isinstance(value, list): - return [self._to_json(v) for v in value] - elif isinstance(value, dict): - return {k: self._to_json(v) for k, v in value.items()} - else: - return value - - def as_dict(self): - column_names = self.__table__.columns.keys() - return {c: self._to_json(getattr(self, c)) for c in column_names} - - -class SurrogatePK(object): - """A mixin that adds a surrogate integer 'primary key' column named ``id`` to any declarative-mapped class.""" - - __table_args__ = {'extend_existing': True} - - id = Column(db.Integer, primary_key=True) - - @classmethod - def get_by_id(cls, record_id): - """Get record by ID.""" - if any( - ( - isinstance(record_id, (str, bytes)) and record_id.isdigit(), - isinstance(record_id, (int, float)), - ) - ): - return cls.query.get(int(record_id)) - return None - - -def reference_col(table_name, nullable=False, pk_name='id', **kwargs): - """Column that adds primary key foreign key reference. - - Usage: :: - - category_id = reference_col('category') - category = relationship('Category', backref='categories') - """ - return Column(db.ForeignKey('{0}.{1}'.format(table_name, pk_name)), nullable=nullable, **kwargs) +__all__ = ["Base", "User", "Column", "db", "Meta"] diff --git a/models/database_model/base_model.py b/models/database_model/base_model.py new file mode 100644 index 0000000..7954f57 --- /dev/null +++ b/models/database_model/base_model.py @@ -0,0 +1,106 @@ +import enum +from datetime import datetime + +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.ext.declarative import declarative_base + +from configures.settings import date_format + +Meta = declarative_base() +db = SQLAlchemy(model_class=Meta) + +Column = db.Column +relationship = db.relationship + + +class SurrogatePK: + """A mixin that adds a surrogate integer 'primary key' column named ``id`` to any declarative-mapped class.""" + + __table_args__ = {'extend_existing': True} + + id = Column(db.Integer, primary_key=True) + + @classmethod + def get_by_id(cls, record_id): + """Get record by ID.""" + if any( + ( + isinstance(record_id, (str, bytes)) and record_id.isdigit(), + isinstance(record_id, (int, float)), + ) + ): + return cls.query.get(int(record_id)) + return None + + +class Base(db.Model, SurrogatePK): + """DataBase Model that Contains CRUD Operations""" + + __abstract__ = True + + @classmethod + def parse_schema(cls, **kwargs): + column_names = cls.__table__.columns.keys() + for key in filter(lambda col_name: col_name not in column_names, kwargs.copy().keys()): + kwargs.pop(key) + + return kwargs + + @classmethod + def create(cls, commit=True, **kwargs): + kwargs = cls.parse_schema(**kwargs) + instance = cls(**kwargs) + + return instance.save(commit) + + @classmethod + def create_many(cls, kwargs_list): + return [cls.create(**kwargs) for kwargs in kwargs_list] + + def update(self, commit=True, **kwargs): + """Update specific fields of a record.""" + kwargs = self.parse_schema(**kwargs) + for attr, value in kwargs.items(): + setattr(self, attr, value) + return commit and self.save() or self + + def delete(self, commit=True): + """Remove the record from the database.""" + db.session.delete(self) + return commit and db.session.commit() + + def save(self, commit=True): + """Save the record.""" + db.session.add(self) + if commit: + db.session.commit() + return self + + def _to_json(self, value): + if hasattr(value, "as_dict"): + return value.as_dict() + elif isinstance(value, datetime): + return value.strftime(date_format) + elif isinstance(value, enum.Enum): + return value.name + elif isinstance(value, list): + return [self._to_json(v) for v in value] + elif isinstance(value, dict): + return {k: self._to_json(v) for k, v in value.items()} + else: + return value + + def as_dict(self): + column_names = self.__table__.columns.keys() + return {c: self._to_json(getattr(self, c)) for c in column_names} + + +def reference_col(table_name, nullable=False, pk_name='id', **kwargs): + """Column that adds primary key foreign key reference. + + Usage: :: + + category_id = reference_col('category') + category = relationship('Category', backref='categories') + """ + return Column(db.ForeignKey('{0}.{1}'.format(table_name, pk_name)), nullable=nullable, **kwargs) diff --git a/models/database_model/user_model.py b/models/database_model/user_model.py index 2c3292f..51700d1 100644 --- a/models/database_model/user_model.py +++ b/models/database_model/user_model.py @@ -10,12 +10,13 @@ String, Table, Text, + func, ) -from models.database_model import Base, Column, SurrogatePK +from models.database_model import Base, Column -class User(SurrogatePK, Base): +class User(Base): __tablename__ = 'user' id = Column(Integer, primary_key=True) @@ -55,3 +56,4 @@ class User(SurrogatePK, Base): time_zone = Column(String(256), default="utc", comment="默认使用的时区") deleted = Column(Boolean, default=0, comment="该用户是否注销") + create_time = Column(DateTime, nullable=False, server_default=func.now()) diff --git a/tests/test_user.py b/tests/test_user.py index fc7f930..e598973 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,3 +1,5 @@ +from datetime import datetime + from tests import BaseTestCase @@ -10,7 +12,11 @@ def test_add_user(self): exist_user = self.client.get(self.url_prefix).json["data"] self.assertEqual(len(exist_user), 0) - new_user = {"email": "suepr76rui@icloud.com", "password": "b22sw1*#DJfyxoUaq"} + new_user = { + "email": "suepr76rui@icloud.com", + "password": "b22sw1*#DJfyxoUaq", + "create_time": datetime.now(), + } self.client.post(self.url_prefix, json=new_user) users = self.client.get(self.url_prefix).json["data"] self.assertEqual(len(users), 1) @@ -162,3 +168,12 @@ def test_delete(self): resp = self.client.get(self.url_prefix).json["data"] self.assertEqual(0, len(resp)) + + def test_get_user_not_existed(self): + resp = self.client.get(self.url_prefix + "/1") + + error_msg = resp.json + error_msg.pop("traceback") + + expect_error_msg = {'error_code': 404, 'error_msg': '用户 <1> 不存在'} + self.assertDictEqual(error_msg, expect_error_msg)