diff --git a/README.md b/README.md index 17f6de3..db3d1bd 100644 --- a/README.md +++ b/README.md @@ -54,4 +54,17 @@ - [x] info - [ ] help - [x] credits `new command` - \ No newline at end of file + + --- + + # Cogs.ci commands + - [ ] ci (help?) + - [ ] ci show + - [ ] ci register + - [ ] ci delete + - [ ] ci update + - [ ] ci setconfig + - [ ] ci setos + - [ ] ci setcountry + - [ ] ci online_edit `renamed`, cause : `website down` + - [ ] ci list \ No newline at end of file diff --git a/bot.py b/bot.py index f2cfb2c..3ea4eab 100755 --- a/bot.py +++ b/bot.py @@ -5,6 +5,7 @@ import traceback from collections import deque import aiohttp +import asyncpg import discord import git from discord.ext import commands @@ -37,9 +38,9 @@ async def _prefix_callable(bot, message: discord.message) -> list: class TuxBot(commands.AutoShardedBot): - __slots__ = ('uptime', 'config', 'session') + __slots__ = ('uptime', 'config', 'db', 'session') - def __init__(self, unload: list): + def __init__(self, unload: list, db: asyncpg.pool.Pool): super().__init__(command_prefix=_prefix_callable, pm_help=None, help_command=None, description=description, help_attrs=dict(hidden=True), @@ -47,6 +48,7 @@ class TuxBot(commands.AutoShardedBot): self.uptime: datetime = datetime.datetime.utcnow() self.config = config + self.db = db self._prev_events = deque(maxlen=10) self.session = aiohttp.ClientSession(loop=self.loop) diff --git a/cogs/admin.py b/cogs/admin.py index 350d9b4..9e8a0f6 100644 --- a/cogs/admin.py +++ b/cogs/admin.py @@ -3,6 +3,7 @@ from typing import Union import discord from discord.ext import commands +import humanize from bot import TuxBot from .utils.lang import Texts @@ -12,8 +13,9 @@ class Admin(commands.Cog): def __init__(self, bot: TuxBot): self.bot = bot + self.db = bot.db - async def cog_check(self, ctx: commands.Context): + async def cog_check(self, ctx: commands.Context) -> bool: permissions: discord.Permissions = ctx.channel.permissions_for( ctx.author) @@ -217,6 +219,49 @@ class Admin(commands.Cog): await ctx.send(Texts('utils').get("Unable to find the message"), delete_after=5) + """---------------------------------------------------------------------""" + + @commands.group(name='warn', aliases=['warns']) + async def _warn(self, ctx: commands.Context): + if ctx.invoked_subcommand is None: + query = """ + SELECT user_id, reason, created_at FROM warns + WHERE created_at >= $1 AND server_id = $2 + ORDER BY created_at + DESC LIMIT 10 + """ + week_ago = datetime.datetime.now() - datetime.timedelta(weeks=6) + + async with self.bot.db.acquire() as con: + warns = await con.fetch(query, week_ago, ctx.guild.id) + warns_list = '' + + for warn in warns: + user_id = warn.get('user_id') + user = await self.bot.fetch_user(user_id) + reason = warn.get('reason') + ago = humanize.naturaldelta( + datetime.datetime.now() - warn.get('created_at') + ) + + warns_list += f"**{user}**: `{reason}` *({ago} ago)*\n" + + e = discord.Embed( + title=f"{len(warns)} {Texts('admin').get('last warns')}: ", + description=warns_list + ) + + await ctx.send(embed=e) + + @_warn.command(name='add', aliases=['new']) + async def _warn_new(self, ctx: commands.Context, member: discord.Member, + *, reason): + """ + todo: push in database + if warn > 2 for member: + todo: ask for confirmation to kick or ban + """ + def setup(bot: TuxBot): bot.add_cog(Admin(bot)) diff --git a/cogs/utils/db.py b/cogs/utils/db.py index 90c7604..554a7e6 100755 --- a/cogs/utils/db.py +++ b/cogs/utils/db.py @@ -1,1085 +1,12 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2017 Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" - -# These are just things that allow me to make tables for PostgreSQL easier -# This isn't exactly good. It's just good enough for my uses. -# Also shoddy migration support. - -import asyncio -import datetime -import decimal -import inspect -import json import logging -import pydoc -import uuid -from collections import OrderedDict -from pathlib import Path import asyncpg log = logging.getLogger(__name__) -class SchemaError(Exception): - pass - - -class SQLType: - python = None - - def to_dict(self): - o = self.__dict__.copy() - cls = self.__class__ - o['__meta__'] = cls.__module__ + '.' + cls.__qualname__ - return o - +class Table: @classmethod - def from_dict(cls, data): - meta = data.pop('__meta__') - given = cls.__module__ + '.' + cls.__qualname__ - if given != meta: - cls = pydoc.locate(meta) - if cls is None: - raise RuntimeError('Could not locate "%s".' % meta) - - self = cls.__new__(cls) - self.__dict__.update(data) - return self - - def __eq__(self, other): - return isinstance(other, - self.__class__) and self.__dict__ == other.__dict__ - - def __ne__(self, other): - return not self.__eq__(other) - - def to_sql(self): - raise NotImplementedError() - - def is_real_type(self): - return True - - -class Binary(SQLType): - python = bytes - - def to_sql(self): - return 'BYTEA' - - -class Boolean(SQLType): - python = bool - - def to_sql(self): - return 'BOOLEAN' - - -class Date(SQLType): - python = datetime.date - - def to_sql(self): - return 'DATE' - - -class Datetime(SQLType): - python = datetime.datetime - - def __init__(self, *, timezone=False): - self.timezone = timezone - - def to_sql(self): - if self.timezone: - return 'TIMESTAMP WITH TIME ZONE' - return 'TIMESTAMP' - - -class Double(SQLType): - python = float - - def to_sql(self): - return 'REAL' - - -class Float(SQLType): - python = float - - def to_sql(self): - return 'FLOAT' - - -class Integer(SQLType): - python = int - - def __init__(self, *, big=False, small=False, auto_increment=False): - self.big = big - self.small = small - self.auto_increment = auto_increment - - if big and small: - raise SchemaError( - 'Integer column type cannot be both big and small.') - - def to_sql(self): - if self.auto_increment: - if self.big: - return 'BIGSERIAL' - if self.small: - return 'SMALLSERIAL' - return 'SERIAL' - if self.big: - return 'BIGINT' - if self.small: - return 'SMALLINT' - return 'INTEGER' - - def is_real_type(self): - return not self.auto_increment - - -class Interval(SQLType): - python = datetime.timedelta - - def __init__(self, field=None): - if field: - field = field.upper() - if field not in ( - 'YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE', 'SECOND', - 'YEAR TO MONTH', 'DAY TO HOUR', 'DAY TO MINUTE', 'DAY TO SECOND', - 'HOUR TO MINUTE', 'HOUR TO SECOND', 'MINUTE TO SECOND'): - raise SchemaError('invalid interval specified') - self.field = field - else: - self.field = None - - def to_sql(self): - if self.field: - return 'INTERVAL ' + self.field - return 'INTERVAL' - - -class Numeric(SQLType): - python = decimal.Decimal - - def __init__(self, *, precision=None, scale=None): - if precision is not None: - if precision < 0 or precision > 1000: - raise SchemaError( - 'precision must be greater than 0 and below 1000') - if scale is None: - scale = 0 - - self.precision = precision - self.scale = scale - - def to_sql(self): - if self.precision is not None: - return 'NUMERIC({0.precision}, {0.scale})'.format(self) - return 'NUMERIC' - - -class String(SQLType): - python = str - - def __init__(self, *, length=None, fixed=False): - self.length = length - self.fixed = fixed - - if fixed and length is None: - raise SchemaError('Cannot have fixed string with no length') - - def to_sql(self): - if self.length is None: - return 'TEXT' - if self.fixed: - return 'CHAR({0.length})'.format(self) - return 'VARCHAR({0.length})'.format(self) - - -class Time(SQLType): - python = datetime.time - - def __init__(self, *, timezone=False): - self.timezone = timezone - - def to_sql(self): - if self.timezone: - return 'TIME WITH TIME ZONE' - return 'TIME' - - -class JSON(SQLType): - python = None - - def to_sql(self): - return 'JSONB' - - -class ForeignKey(SQLType): - def __init__(self, table, column, *, sql_type=None, on_delete='CASCADE', - on_update='NO ACTION'): - if not table or not isinstance(table, str): - raise SchemaError('missing table to reference (must be string)') - - valid_actions = ( - 'NO ACTION', - 'RESTRICT', - 'CASCADE', - 'SET NULL', - 'SET DEFAULT', - ) - - on_delete = on_delete.upper() - on_update = on_update.upper() - - if on_delete not in valid_actions: - raise TypeError('on_delete must be one of %s.' % valid_actions) - - if on_update not in valid_actions: - raise TypeError('on_update must be one of %s.' % valid_actions) - - self.table = table - self.column = column - self.on_update = on_update - self.on_delete = on_delete - - if sql_type is None: - sql_type = Integer - - if inspect.isclass(sql_type): - sql_type = sql_type() - - if not isinstance(sql_type, SQLType): - raise TypeError('Cannot have non-SQLType derived sql_type') - - if not sql_type.is_real_type(): - raise SchemaError('sql_type must be a "real" type') - - self.sql_type = sql_type.to_sql() - - def is_real_type(self): - return False - - def to_sql(self): - fmt = '{0.sql_type} REFERENCES {0.table} ({0.column})' \ - ' ON DELETE {0.on_delete} ON UPDATE {0.on_update}' - return fmt.format(self) - - -class Array(SQLType): - python = list - - def __init__(self, sql_type): - if inspect.isclass(sql_type): - sql_type = sql_type() - - if not isinstance(sql_type, SQLType): - raise TypeError('Cannot have non-SQLType derived sql_type') - - if not sql_type.is_real_type(): - raise SchemaError('sql_type must be a "real" type') - - self.sql_type = sql_type.to_sql() - - def to_sql(self): - return '{0.sql_type} ARRAY'.format(self) - - def is_real_type(self): - # technically, it is a real type - # however, it doesn't play very well with migrations - # so we're going to pretend that it isn't - return False - - -class Column: - __slots__ = ('column_type', 'index', 'primary_key', 'nullable', - 'default', 'unique', 'name', 'index_name') - - def __init__(self, column_type, *, index=False, primary_key=False, - nullable=True, unique=False, default=None, name=None): - - if inspect.isclass(column_type): - column_type = column_type() - - if not isinstance(column_type, SQLType): - raise TypeError('Cannot have a non-SQLType derived column_type') - - self.column_type = column_type - self.index = index - self.unique = unique - self.primary_key = primary_key - self.nullable = nullable - self.default = default - self.name = name - self.index_name = None # to be filled later - - if sum(map(bool, (unique, primary_key, default is not None))) > 1: - raise SchemaError( - "'unique', 'primary_key', and 'default' are mutually exclusive.") - - @classmethod - def from_dict(cls, data): - index_name = data.pop('index_name', None) - column_type = data.pop('column_type') - column_type = SQLType.from_dict(column_type) - self = cls(column_type=column_type, **data) - self.index_name = index_name - return self - - @property - def _comparable_id(self): - return '-'.join( - '%s:%s' % (attr, getattr(self, attr)) for attr in self.__slots__) - - def _to_dict(self): - d = { - attr: getattr(self, attr) - for attr in self.__slots__ - } - d['column_type'] = self.column_type.to_dict() - return d - - def _qualifiers_dict(self): - return {attr: getattr(self, attr) for attr in ('nullable', 'default')} - - def _is_rename(self, other): - if self.name == other.name: - return False - - return self.unique == other.unique and self.primary_key == other.primary_key - - def _create_table(self): - builder = [] - builder.append(self.name) - builder.append(self.column_type.to_sql()) - - default = self.default - if default is not None: - builder.append('DEFAULT') - if isinstance(default, str) and isinstance(self.column_type, - String): - builder.append("'%s'" % default) - elif isinstance(default, bool): - builder.append(str(default).upper()) - else: - builder.append("(%s)" % default) - elif self.unique: - builder.append('UNIQUE') - if not self.nullable: - builder.append('NOT NULL') - - return ' '.join(builder) - - -class PrimaryKeyColumn(Column): - """Shortcut for a SERIAL PRIMARY KEY column.""" - - def __init__(self): - super().__init__(Integer(auto_increment=True), primary_key=True) - - -class SchemaDiff: - __slots__ = ('table', 'upgrade', 'downgrade') - - def __init__(self, table, upgrade, downgrade): - self.table = table - self.upgrade = upgrade - self.downgrade = downgrade - - def to_dict(self): - return {'upgrade': self.upgrade, 'downgrade': self.downgrade} - - def is_empty(self): - return len(self.upgrade) == 0 and len(self.downgrade) == 0 - - def to_sql(self, *, downgrade=False): - statements = [] - base = 'ALTER TABLE %s ' % self.table.__tablename__ - path = self.upgrade if not downgrade else self.downgrade - - for rename in path.get('rename_columns', []): - fmt = '{0}RENAME COLUMN {1[before]} TO {1[after]};'.format(base, - rename) - statements.append(fmt) - - sub_statements = [] - for dropped in path.get('remove_columns', []): - fmt = 'DROP COLUMN {0[name]} RESTRICT'.format(dropped) - sub_statements.append(fmt) - - for changed_types in path.get('changed_column_types', []): - fmt = 'ALTER COLUMN {0[name]} SET DATA TYPE {0[type]}'.format( - changed_types) - - using = changed_types.get('using') - if using is not None: - fmt = '%s USING %s' % (fmt, using) - - sub_statements.append(fmt) - - for constraints in path.get('changed_constraints', []): - before, after = constraints['before'], constraints['after'] - - before_default, after_default = before.get('default'), after.get( - 'default') - if before_default is None and after_default is not None: - fmt = 'ALTER COLUMN {0[name]} SET DEFAULT {1[default]}'.format( - constraints, after) - sub_statements.append(fmt) - elif before_default is not None and after_default is None: - fmt = 'ALTER COLUMN {0[name]} DROP DEFAULT'.format(constraints) - sub_statements.append(fmt) - - before_nullable, after_nullable = before.get( - 'nullable'), after.get('nullable') - if not before_nullable and after_nullable: - fmt = 'ALTER COLUMN {0[name]} DROP NOT NULL'.format( - constraints) - sub_statements.append(fmt) - elif before_nullable and not after_nullable: - fmt = 'ALTER COLUMN {0[name]} SET NOT NULL'.format(constraints) - sub_statements.append(fmt) - - for added in path.get('add_columns', []): - column = Column.from_dict(added) - sub_statements.append('ADD COLUMN ' + column._create_table()) - - if sub_statements: - statements.append(base + ', '.join(sub_statements) + ';') - - # handle the index creation bits - for dropped in path.get('drop_index', []): - statements.append( - 'DROP INDEX IF EXISTS {0[index]};'.format(dropped)) - - for added in path.get('add_index', []): - fmt = 'CREATE INDEX IF NOT EXISTS {0[index]} ON {1.__tablename__} ({0[name]});' - statements.append(fmt.format(added, self.table)) - - return '\n'.join(statements) - - -class MaybeAcquire: - def __init__(self, connection, *, pool): - self.connection = connection - self.pool = pool - self._cleanup = False - - async def __aenter__(self): - if self.connection is None: - self._cleanup = True - self._connection = c = await self.pool.acquire() - return c - return self.connection - - async def __aexit__(self, *args): - if self._cleanup: - await self.pool.release(self._connection) - - -class TableMeta(type): - @classmethod - def __prepare__(cls, name, bases, **kwargs): - return OrderedDict() - - def __new__(cls, name, parents, dct, **kwargs): - columns = [] - - try: - table_name = kwargs['table_name'] - except KeyError: - table_name = name.lower() - - dct['__tablename__'] = table_name - - for elem, value in dct.items(): - if isinstance(value, Column): - if value.name is None: - value.name = elem - - if value.index: - value.index_name = '%s_%s_idx' % (table_name, value.name) - - columns.append(value) - - dct['columns'] = columns - return super().__new__(cls, name, parents, dct) - - def __init__(self, name, parents, dct, **kwargs): - super().__init__(name, parents, dct) - - -class Table(metaclass=TableMeta): - @classmethod - async def create_pool(cls, uri, **kwargs): - """Sets up and returns the PostgreSQL connection pool that is used. - - .. note:: - - This must be called at least once before doing anything with the tables. - And must be called on the ``Table`` class. - - Parameters - ----------- - uri: str - The PostgreSQL URI to connect to. - \*\*kwargs - The arguments to forward to asyncpg.create_pool. - """ - - def _encode_jsonb(value): - return json.dumps(value) - - def _decode_jsonb(value): - return json.loads(value) - - old_init = kwargs.pop('init', None) - - async def init(con): - await con.set_type_codec('jsonb', schema='pg_catalog', - encoder=_encode_jsonb, - decoder=_decode_jsonb, format='text') - if old_init is not None: - await old_init(con) - - cls._pool = pool = await asyncpg.create_pool(uri, init=init, **kwargs) - return pool - - @classmethod - def acquire_connection(cls, connection): - return MaybeAcquire(connection, pool=cls._pool) - - @classmethod - def write_migration(cls, *, directory='migrations'): - """Writes the migration diff into the data file. - - Note - ------ - This doesn't actually commit/do the migration. - To do so, use :meth:`migrate`. - - Returns - -------- - bool - ``True`` if a migration was written, ``False`` otherwise. - - Raises - ------- - RuntimeError - Could not find the migration data necessary. - """ - - directory = Path(directory) / cls.__tablename__ - p = directory.with_suffix('.json') - - if not p.exists(): - raise RuntimeError('Could not find migration file.') - - current = directory.with_name('current-' + p.name) - - if not current.exists(): - raise RuntimeError('Could not find current data file.') - - with current.open() as fp: - current_table = cls.from_dict(json.load(fp)) - - diff = cls().diff(current_table) - - # the most common case, no difference - if diff.is_empty(): - return None - - # load the migration data - with p.open('r', encoding='utf-8') as fp: - data = json.load(fp) - migrations = data['migrations'] - - # check if we should add it - our_migrations = diff.to_dict() - if len(migrations) == 0 or migrations[-1] != our_migrations: - # we have a new migration, so add it - migrations.append(our_migrations) - temp_file = p.with_name('%s-%s.tmp' % (uuid.uuid4(), p.name)) - with temp_file.open('w', encoding='utf-8') as tmp: - json.dump(data, tmp, ensure_ascii=True, indent=4) - - temp_file.replace(p) - return True - return False - - @classmethod - async def migrate(cls, *, directory='migrations', index=-1, - downgrade=False, verbose=False, connection=None): - """Actually run the latest migration pointed by the data file. - - Parameters - ----------- - directory: str - The directory of where the migration data file resides. - index: int - The index of the migration array to use. - downgrade: bool - Whether to run an upgrade or a downgrade. - verbose: bool - Whether to output some information to stdout. - connection: Optional[asyncpg.Connection] - The connection to use, if not provided will acquire one from - the internal pool. - """ - - directory = Path(directory) / cls.__tablename__ - p = directory.with_suffix('.json') - if not p.exists(): - raise RuntimeError('Could not find migration file.') - - with p.open('r', encoding='utf-8') as fp: - data = json.load(fp) - migrations = data['migrations'] - - try: - migration = migrations[index] - except IndexError: - return False - - diff = SchemaDiff(cls, migration['upgrade'], migration['downgrade']) - if diff.is_empty(): - return False - - async with MaybeAcquire(connection, pool=cls._pool) as con: - sql = diff.to_sql(downgrade=downgrade) - if verbose: - print(sql) - await con.execute(sql) - - current = directory.with_name('current-' + p.name) - with current.open('w', encoding='utf-8') as fp: - json.dump(cls.to_dict(), fp, indent=4, ensure_ascii=True) - - @classmethod - async def create(cls, *, directory='migrations', verbose=False, - connection=None, run_migrations=True): - """Creates the database and manages migrations, if any. - - Parameters - ----------- - directory: str - The migrations directory. - verbose: bool - Whether to output some information to stdout. - connection: Optional[asyncpg.Connection] - The connection to use, if not provided will acquire one from - the internal pool. - run_migrations: bool - Whether to run migrations at all. - - Returns - -------- - Optional[bool] - ``True`` if the table was successfully created or - ``False`` if the table was successfully migrated or - ``None`` if no migration took place. - """ - directory = Path(directory) / cls.__tablename__ - p = directory.with_suffix('.json') - current = directory.with_name('current-' + p.name) - - table_data = cls.to_dict() - - if not p.exists(): - p.parent.mkdir(parents=True, exist_ok=True) - - # we're creating this table for the first time, - # it's an uncommon case so let's get it out of the way - # first, try to actually create the table - async with MaybeAcquire(connection, pool=cls._pool) as con: - sql = cls.create_table(exists_ok=True) - if verbose: - print(sql) - await con.execute(sql) - - # since that step passed, let's go ahead and make the migration - with p.open('w', encoding='utf-8') as fp: - data = {'table': table_data, 'migrations': []} - json.dump(data, fp, indent=4, ensure_ascii=True) - - with current.open('w', encoding='utf-8') as fp: - json.dump(table_data, fp, indent=4, ensure_ascii=True) - - return True - - if not run_migrations: - return None - - with current.open() as fp: - current_table = cls.from_dict(json.load(fp)) - - diff = cls().diff(current_table) - - # the most common case, no difference - if diff.is_empty(): - return None - - # execute the upgrade SQL - async with MaybeAcquire(connection, pool=cls._pool) as con: - sql = diff.to_sql() - if verbose: - print(sql) - await con.execute(sql) - - # load the migration data - with p.open('r', encoding='utf-8') as fp: - data = json.load(fp) - migrations = data['migrations'] - - # check if we should add it - our_migrations = diff.to_dict() - if len(migrations) == 0 or migrations[-1] != our_migrations: - # we have a new migration, so add it - migrations.append(our_migrations) - temp_file = p.with_name('%s-%s.tmp' % (uuid.uuid4(), p.name)) - with temp_file.open('w', encoding='utf-8') as tmp: - json.dump(data, tmp, ensure_ascii=True, indent=4) - - temp_file.replace(p) - - # update our "current" data in the filesystem - with current.open('w', encoding='utf-8') as fp: - json.dump(table_data, fp, indent=4, ensure_ascii=True) - - return False - - @classmethod - async def drop(cls, *, directory='migrations', verbose=False, - connection=None): - """Drops the database and migrations, if any. - - Parameters - ----------- - directory: str - The migrations directory. - verbose: bool - Whether to output some information to stdout. - connection: Optional[asyncpg.Connection] - The connection to use, if not provided will acquire one from - the internal pool. - """ - - directory = Path(directory) / cls.__tablename__ - p = directory.with_suffix('.json') - current = directory.with_name('current-' + p.name) - - if not p.exists() or not current.exists(): - raise RuntimeError('Could not find the appropriate data files.') - - try: - p.unlink() - except: - raise RuntimeError('Could not delete migration file') - - try: - current.unlink() - except: - raise RuntimeError('Could not delete current migration file') - - async with MaybeAcquire(connection, pool=cls._pool) as con: - sql = 'DROP TABLE {0} CASCADE;'.format(cls.__tablename__) - if verbose: - print(sql) - await con.execute(sql) - - @classmethod - def create_table(cls, *, exists_ok=True): - """Generates the CREATE TABLE stub.""" - statements = [] - builder = ['CREATE TABLE'] - - if exists_ok: - builder.append('IF NOT EXISTS') - - builder.append(cls.__tablename__) - column_creations = [] - primary_keys = [] - for col in cls.columns: - column_creations.append(col._create_table()) - if col.primary_key: - primary_keys.append(col.name) - - column_creations.append('PRIMARY KEY (%s)' % ', '.join(primary_keys)) - builder.append('(%s)' % ', '.join(column_creations)) - statements.append(' '.join(builder) + ';') - - # handle the index creations - for column in cls.columns: - if column.index: - fmt = 'CREATE INDEX IF NOT EXISTS {1.index_name} ON {0} ({1.name});'.format( - cls.__tablename__, column) - statements.append(fmt) - - return '\n'.join(statements) - - @classmethod - async def insert(cls, connection=None, **kwargs): - """Inserts an element to the table.""" - - # verify column names: - verified = {} - for column in cls.columns: - try: - value = kwargs[column.name] - except KeyError: - continue - - check = column.column_type.python - if value is None and not column.nullable: - raise TypeError( - 'Cannot pass None to non-nullable column %s.' % column.name) - elif not check or not isinstance(value, check): - fmt = 'column {0.name} expected {1.__name__}, received {2.__class__.__name__}' - raise TypeError(fmt.format(column, check, value)) - - verified[column.name] = value - - sql = 'INSERT INTO {0} ({1}) VALUES ({2});'.format(cls.__tablename__, - ', '.join(verified), - ', '.join( - '$' + str(i) for - i, _ in - enumerate( - verified, - 1))) - - async with MaybeAcquire(connection, pool=cls._pool) as con: - await con.execute(sql, *verified.values()) - - @classmethod - def to_dict(cls): - x = {} - x['name'] = cls.__tablename__ - x['__meta__'] = cls.__module__ + '.' + cls.__qualname__ - - # nb: columns is ordered due to the ordered dict usage - # this is used to help detect renames - x['columns'] = [a._to_dict() for a in cls.columns] - return x - - @classmethod - def from_dict(cls, data): - meta = data['__meta__'] - given = cls.__module__ + '.' + cls.__qualname__ - if given != meta: - cls = pydoc.locate(meta) - if cls is None: - raise RuntimeError('Could not locate "%s".' % meta) - - self = cls() - self.__tablename__ = data['name'] - self.columns = [Column.from_dict(a) for a in data['columns']] - return self - - @classmethod - def all_tables(cls): - return cls.__subclasses__() - - def diff(self, before): - """Outputs the upgrade and downgrade path in JSON. - - This isn't necessarily good, but it outputs it in a format - that allows the user to manually make edits if something is wrong. - - The following JSON schema is used: - - Note that every major key takes a list of objects as noted below. - - Note that add_column and drop_column automatically create and drop - indices as necessary. - - changed_column_types: - name: str [The column name] - type: str [The new column type] - using: Optional[str] [The USING expression to use, if applicable] - add_columns: - column: object - remove_columns: - column: object - rename_columns: - before: str [The previous column name] - after: str [The new column name] - drop_index: - name: str [The column name] - index: str [The index name] - add_index: - name: str [The column name] - index: str [The index name] - changed_constraints: - name: str [The column name] - before: - nullable: Optional[bool] - default: Optional[str] - after: - nullable: Optional[bool] - default: Optional[str] - """ - upgrade = {} - downgrade = {} - - def check_index_diff(a, b): - if a.index != b.index: - # Let's assume we have {name: thing, index: True} - # and we're going to { name: foo, index: False } - # This is a 'dropped' column when we upgrade with a rename - # care must be taken to use the old name when dropping - - # check if we're dropping the index - if not a.index: - # we could also be renaming so make sure to use the old index name - upgrade.setdefault('drop_index', []).append( - {'name': a.name, 'index': b.index_name}) - # if we want to roll back, we need to re-add the old index to the old column name - downgrade.setdefault('add_index', []).append( - {'name': b.name, 'index': b.index_name}) - else: - # we're not dropping an index, instead we're adding one - upgrade.setdefault('add_index', []).append( - {'name': a.name, 'index': a.index_name}) - downgrade.setdefault('drop_index', []).append( - {'name': a.name, 'index': a.index_name}) - - def insert_column_diff(a, b): - if a.column_type != b.column_type: - if a.name == b.name and a.column_type.is_real_type() and b.column_type.is_real_type(): - upgrade.setdefault('changed_column_types', []).append( - {'name': a.name, 'type': a.column_type.to_sql()}) - downgrade.setdefault('changed_column_types', []).append( - {'name': a.name, 'type': b.column_type.to_sql()}) - else: - a_dict, b_dict = a._to_dict(), b._to_dict() - upgrade.setdefault('add_columns', []).append(a_dict) - upgrade.setdefault('remove_columns', []).append(b_dict) - downgrade.setdefault('remove_columns', []).append(a_dict) - downgrade.setdefault('add_columns', []).append(b_dict) - check_index_diff(a, b) - return - - elif a._is_rename(b): - upgrade.setdefault('rename_columns', []).append( - {'before': b.name, 'after': a.name}) - downgrade.setdefault('rename_columns', []).append( - {'before': a.name, 'after': b.name}) - - # technically, adding UNIQUE or PRIMARY KEY is rather simple and straight forward - # however, since the inverse is a little bit more complicated (you have to remove - # the index it maintains and you can't easily know what it is), it's not exactly - # worth supporting any sort of change to the uniqueness/primary_key as it stands. - # So.. just drop/add the column and call it a day. - if a.unique != b.unique or a.primary_key != b.primary_key: - a_dict, b_dict = a._to_dict(), b._to_dict() - upgrade.setdefault('add_columns', []).append(a_dict) - upgrade.setdefault('remove_columns', []).append(b_dict) - downgrade.setdefault('remove_columns', []).append(a_dict) - downgrade.setdefault('add_columns', []).append(b_dict) - check_index_diff(a, b) - return - - check_index_diff(a, b) - - b_qual, a_qual = b._qualifiers_dict(), a._qualifiers_dict() - if a_qual != b_qual: - upgrade.setdefault('changed_constraints', []).append( - {'name': a.name, 'before': b_qual, 'after': a_qual}) - downgrade.setdefault('changed_constraints', []).append( - {'name': a.name, 'before': a_qual, 'after': b_qual}) - - if len(self.columns) == len(before.columns): - # check if we have any changes at all - for a, b in zip(self.columns, before.columns): - if a._comparable_id == b._comparable_id: - # no change - continue - insert_column_diff(a, b) - - elif len(self.columns) > len(before.columns): - # check if we have more columns - # typically when we add columns we add them at the end of - # the table, this assumption makes this particularly bit easier. - # Breaking this assumption will probably break this portion and thus - # will require manual handling, sorry. - - for a, b in zip(self.columns, before.columns): - if a._comparable_id == b._comparable_id: - # no change - continue - insert_column_diff(a, b) - - new_columns = self.columns[len(before.columns):] - add, remove = upgrade.setdefault('add_columns', - []), downgrade.setdefault( - 'remove_columns', []) - for column in new_columns: - as_dict = column._to_dict() - add.append(as_dict) - remove.append(as_dict) - if column.index: - upgrade.setdefault('add_index', []).append( - {'name': column.name, 'index': column.index_name}) - downgrade.setdefault('drop_index', []).append( - {'name': column.name, 'index': column.index_name}) - - elif len(self.columns) < len(before.columns): - # check if we have fewer columns - # this one is a little bit more complicated - - # first we sort the columns by comparable IDs. - sorted_before = sorted(before.columns, - key=lambda c: c._comparable_id) - sorted_after = sorted(self.columns, key=lambda c: c._comparable_id) - - # handle the column diffs: - for a, b in zip(sorted_after, sorted_before): - if a._comparable_id == b._comparable_id: - continue - insert_column_diff(a, b) - - # check which columns are 'left over' and remove them - removed = [c._to_dict() for c in sorted_before[len(sorted_after):]] - upgrade.setdefault('remove_columns', []).extend(removed) - downgrade.setdefault('add_columns', []).extend(removed) - - return SchemaDiff(self, upgrade, downgrade) - - -async def _table_creator(tables, *, verbose=True): - for table in tables: - try: - await table.create(verbose=verbose) - except: - log.error('Failed to create table %s.', table.__tablename__) - - -def create_tables(*tables, verbose=True, loop=None): - if loop is None: - loop = asyncio.get_event_loop() - - loop.create_task(_table_creator(tables, verbose=verbose)) + async def create_pool(cls, uri, **kwargs) -> asyncpg.pool.Pool: + cls._pool = db = await asyncpg.create_pool(uri, **kwargs) + return db diff --git a/extras/locales/en/LC_MESSAGES/admin.po b/extras/locales/en/LC_MESSAGES/admin.po index 12b07d3..46df0e2 100644 --- a/extras/locales/en/LC_MESSAGES/admin.po +++ b/extras/locales/en/LC_MESSAGES/admin.po @@ -22,4 +22,7 @@ msgid "Unable to ban this user" msgstr "" msgid "Unable to kick this user" +msgstr "" + +msgid "last warns" msgstr "" \ No newline at end of file diff --git a/extras/locales/fr/LC_MESSAGES/admin.mo b/extras/locales/fr/LC_MESSAGES/admin.mo index 139da3d..70fc673 100644 Binary files a/extras/locales/fr/LC_MESSAGES/admin.mo and b/extras/locales/fr/LC_MESSAGES/admin.mo differ diff --git a/extras/locales/fr/LC_MESSAGES/admin.po b/extras/locales/fr/LC_MESSAGES/admin.po index 338992d..e3601b4 100644 --- a/extras/locales/fr/LC_MESSAGES/admin.po +++ b/extras/locales/fr/LC_MESSAGES/admin.po @@ -22,4 +22,7 @@ msgid "Unable to ban this user" msgstr "Impossible de bannir cet utilisateur" msgid "Unable to kick this user" -msgstr "Impossible d'expulser cet utilisateur" \ No newline at end of file +msgstr "Impossible d'expulser cet utilisateur" + +msgid "last warns" +msgstr "derniers avertissements" \ No newline at end of file diff --git a/launcher.py b/launcher.py index f13a703..e00fe11 100644 --- a/launcher.py +++ b/launcher.py @@ -4,7 +4,9 @@ import logging import socket import sys +import asyncpg import click +import git import requests from bot import TuxBot @@ -50,17 +52,16 @@ def run_bot(unload: list = []): print(Texts().get('Starting...')) try: - pool = loop.run_until_complete( + db = loop.run_until_complete( Table.create_pool(config.postgresql, command_timeout=60) ) - except socket.gaierror as e: + except socket.gaierror: click.echo(Texts().get("Could not set up PostgreSQL..."), file=sys.stderr) log.exception(Texts().get("Could not set up PostgreSQL...")) return - bot = TuxBot(unload) - bot.pool = pool + bot = TuxBot(unload, db) bot.run()