diff --git a/src/snek/app.py b/src/snek/app.py index f26fc06..bc0884a 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -14,6 +14,7 @@ from snek.docs.app import Application as DocsApplication from snek.mapper import get_mappers from snek.service import get_services from snek.system import http +from snek.system.cache import Cache from snek.system.markdown import MarkdownExtension from snek.system.middleware import cors_middleware from snek.view.about import AboutHTMLView, AboutMDView @@ -53,11 +54,9 @@ class Application(BaseApplication): self._middlewares.append(session_middleware) self.jinja2_env.add_extension(MarkdownExtension) self.setup_router() - self.setup_services() - - def setup_services(self): - self.services = SimpleNamespace(**get_services(app=self)) - self.mappers = SimpleNamespace(**get_mappers(app=self)) + self.cache = Cache(self) + self.services = get_services(app=self) + self.mappers = get_mappers(app=self) def setup_router(self): self.router.add_get("/", IndexView) @@ -76,9 +75,9 @@ class Application(BaseApplication): self.router.add_view("/status.json", StatusView) self.router.add_view("/web.html", WebView) self.router.add_view("/login.html", LoginView) - self.router.add_view("/login.json", LoginFormView) + self.router.add_view("/login.json", LoginView) self.router.add_view("/register.html", RegisterView) - self.router.add_view("/register.json", RegisterFormView) + self.router.add_view("/register.json", RegisterView) self.router.add_get("/http-get", self.handle_http_get) self.router.add_get("/http-photo", self.handle_http_photo) diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py index 2b9b79f..1f29d73 100644 --- a/src/snek/mapper/__init__.py +++ b/src/snek/mapper/__init__.py @@ -1,11 +1,21 @@ import functools +from types import SimpleNamespace +from snek.mapper.channel import ChannelMapper +from snek.mapper.channel_member import ChannelMemberMapper +from snek.mapper.channel_message import ChannelMessageMapper from snek.mapper.user import UserMapper +from snek.system.object import Object @functools.cache def get_mappers(app=None): - return {"user": UserMapper(app=app)} + return Object( + **{"user": UserMapper(app=app), + 'channel_member': ChannelMemberMapper(app=app), + 'channel': ChannelMapper(app=app), + 'channel_message': ChannelMessageMapper(app=app) + }) def get_mapper(name, app=None): diff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py new file mode 100644 index 0000000..6239dc8 --- /dev/null +++ b/src/snek/mapper/channel.py @@ -0,0 +1,7 @@ +from snek.model.channel import ChannelModel +from snek.system.mapper import BaseMapper + + +class ChannelMapper(BaseMapper): + table_name = "channel" + model_class = ChannelModel diff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py new file mode 100644 index 0000000..f0f62d6 --- /dev/null +++ b/src/snek/mapper/channel_member.py @@ -0,0 +1,7 @@ +from snek.model.channel_member import ChannelMemberModel +from snek.system.mapper import BaseMapper + + +class ChannelMemberMapper(BaseMapper): + table_name = "channel_member" + model_class = ChannelMemberModel diff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py new file mode 100644 index 0000000..364c1ee --- /dev/null +++ b/src/snek/mapper/channel_message.py @@ -0,0 +1,7 @@ +from snek.model.channel_message import ChannelMessageModel +from snek.system.mapper import BaseMapper + + +class ChannelMessageMapper(BaseMapper): + model_class = ChannelMessageModel + table_name = "channel_message" \ No newline at end of file diff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py new file mode 100644 index 0000000..e69de29 diff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py index 081ae15..ccb5289 100644 --- a/src/snek/model/__init__.py +++ b/src/snek/model/__init__.py @@ -1,11 +1,19 @@ import functools +from snek.model.channel import ChannelModel +from snek.model.channel_member import ChannelMemberModel +#from snek.model.channel_message import ChannelMessageModel +from snek.model.channel_message import ChannelMessageModel from snek.model.user import UserModel +from snek.system.object import Object @functools.cache def get_models(): - return {"user": UserModel} + return Object(**{"user": UserModel, + "channel_member": ChannelMemberModel, + "channel": ChannelModel, + "channel_message": ChannelMessageModel}) def get_model(name): diff --git a/src/snek/model/channel.py b/src/snek/model/channel.py new file mode 100644 index 0000000..50b1181 --- /dev/null +++ b/src/snek/model/channel.py @@ -0,0 +1,11 @@ +from snek.system.model import BaseModel, ModelField + +class ChannelModel(BaseModel): + label = ModelField(name="label", required=True,kind=str) + description = ModelField(name="description", required=False,kind=str) + tag = ModelField(name="tag", required=False,kind=str) + created_by_uid = ModelField(name="created_by_uid", required=True,kind=str) + is_private = ModelField(name="is_private", required=True,kind=bool,value=False) + is_listed = ModelField(name="is_listed", required=True,kind=bool,value=True) + index = ModelField(name="index", required=True,kind=int,value=1000) + diff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py new file mode 100644 index 0000000..48131e4 --- /dev/null +++ b/src/snek/model/channel_member.py @@ -0,0 +1,10 @@ +from snek.system.model import BaseModel, ModelField + +class ChannelMemberModel(BaseModel): + label = ModelField(name="label", required=True,kind=str) + channel_uid = ModelField(name="channel_uid", required=True,kind=str) + user_uid = ModelField(name="user_uid", required=True,kind=str) + is_moderator = ModelField(name="is_moderator", required=True,kind=bool,value=False) + is_read_only = ModelField(name="is_read_only", required=True,kind=bool,value=False) + is_muted = ModelField(name="is_muted", required=True,kind=bool,value=False) + is_banned = ModelField(name="is_banned", required=True,kind=bool,value=False) diff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py new file mode 100644 index 0000000..9e96307 --- /dev/null +++ b/src/snek/model/channel_message.py @@ -0,0 +1,9 @@ + + +from snek.system.model import BaseModel, ModelField + + +class ChannelMessageModel(BaseModel): + channel_uid = ModelField(name="channel_uid", required=True,kind=str) + user_uid = ModelField(name="user_uid", required=True,kind=str) + message = ModelField(name="message", required=True,kind=str) \ No newline at end of file diff --git a/src/snek/model/notification.py b/src/snek/model/notification.py new file mode 100644 index 0000000..0a5c294 --- /dev/null +++ b/src/snek/model/notification.py @@ -0,0 +1,12 @@ + + + +from snek.system.model import BaseModel, ModelField + + +class NotificationModel(BaseModel): + object_uid = ModelField(name="object_uid", required=True) + object_type = ModelField(name="object_type", required=True) + message = ModelField(name="message", required=True) + user_uid = ModelField(name="user_uid", required=True) + read_at = ModelField(name="is_read", required=True) \ No newline at end of file diff --git a/src/snek/model/user.py b/src/snek/model/user.py index adb236b..97070c4 100644 --- a/src/snek/model/user.py +++ b/src/snek/model/user.py @@ -10,6 +10,13 @@ class UserModel(BaseModel): max_length=20, regex=r"^[a-zA-Z0-9_]+$", ) + nick = ModelField( + name="nick", + required=False, + min_length=2, + max_length=20, + regex=r"^[a-zA-Z0-9_]+$", + ) email = ModelField( name="email", required=False, diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py index 60fec76..8457917 100644 --- a/src/snek/service/__init__.py +++ b/src/snek/service/__init__.py @@ -1,12 +1,21 @@ import functools +from snek.service.channel import ChannelService from snek.service.user import UserService +from snek.service.channel_member import ChannelMemberService +from types import SimpleNamespace +from snek.system.object import Object @functools.cache def get_services(app): - - return {"user": UserService(app=app)} + return Object( + **{ + "user": UserService(app=app), + "channel_member": ChannelMemberService(app=app), + 'channel': ChannelService(app=app) + } +) def get_service(name, app=None): diff --git a/src/snek/service/channel.py b/src/snek/service/channel.py new file mode 100644 index 0000000..9290baf --- /dev/null +++ b/src/snek/service/channel.py @@ -0,0 +1,31 @@ +from snek.system.service import BaseService + +class ChannelService(BaseService): + mapper_name = "channel" + + async def create(self, label, created_by_uid, description=None, tag=None, is_private=False, is_listed=True): + if label[0] != "#" and is_listed: + label = f"#{label}" + count = await self.count(deleted_at=None) + if not tag and not count: + tag = "public" + model = await self.new() + model['label'] = label + model['description'] = description + model['tag'] = tag + model['created_by_uid'] = created_by_uid + model['is_private'] = is_private + model['is_listed'] = is_listed + if await self.save(model): + return model + raise Exception(f"Failed to create channel: {model.errors}.") + + async def ensure_public_channel(self, created_by_uid): + model = await self.get(is_listed=True,tag="public") + is_moderator = False + if not model: + is_moderator = True + model = await self.create("public", created_by_uid=created_by_uid, is_listed=True, tag="public") + await self.app.services.channel_member.create(model['uid'], created_by_uid, is_moderator=is_moderator, is_read_only=False, is_muted=False, is_banned=False) + return model + \ No newline at end of file diff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py new file mode 100644 index 0000000..18d4842 --- /dev/null +++ b/src/snek/service/channel_member.py @@ -0,0 +1,24 @@ +from snek.system.service import BaseService + +class ChannelMemberService(BaseService): + + mapper_name = "channel_member" + + async def create(self, channel_uid, user_uid, is_moderator=False, is_read_only=False, is_muted=False, is_banned=False): + model = await self.get(channel_uid=channel_uid, user_uid=user_uid) + if model: + if model.is_banned.value: + return False + return model + model = await self.new() + channel = await self.services.channel.get(uid=channel_uid) + model['label'] = channel['label'] + model['channel_uid'] = channel_uid + model['user_uid'] = user_uid + model['is_moderator'] = is_moderator + model['is_read_only'] = is_read_only + model['is_muted'] = is_muted + model['is_banned'] = is_banned + if await self.save(model): + return model + raise Exception(f"Failed to create channel member: {model.errors}.") \ No newline at end of file diff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py new file mode 100644 index 0000000..e1c0007 --- /dev/null +++ b/src/snek/service/channel_message.py @@ -0,0 +1,14 @@ +from snek.system.service import BaseService + + +class ChannelMessageService(BaseService): + mapper_name = "channel_message" + + async def create(self, channel_uid, user_uid, message): + model = await self.new() + model['channel_uid'] = channel_uid + model['user_uid'] = user_uid + model['message'] = message + if await self.save(model): + return model + raise Exception(f"Failed to create channel message: {model.errors}.") \ No newline at end of file diff --git a/src/snek/service/notification.py b/src/snek/service/notification.py new file mode 100644 index 0000000..e154323 --- /dev/null +++ b/src/snek/service/notification.py @@ -0,0 +1,30 @@ + + +from snek.system.service import BaseService + + +class NotificationService(BaseService): + mapper_name = "notification" + + async def create(self, object_uid, object_type, user_uid, message): + model = await self.new() + model['object_uid'] = object_uid + model['object_type'] = object_type + model['user_uid'] = user_uid + model['message'] = message + if await self.save(model): + return model + raise Exception(f"Failed to create notification: {model.errors}.") + + async def create_channel_message(self, channel_message_uid): + channel_message = await self.services.channel_message.get(uid=channel_message_uid) + user = await self.services.user.get(uid=channel_message['user_uid']) + async for channel_member in self.services.channel_member.find(channel_uid=channel_message['channel_uid'],is_banned=False,is_muted=False, deleted_at=None): + model = await self.new() + model['object_uid'] = channel_message_uid + model['object_type'] = "channel_message" + model['user_uid'] = channel_member['user_uid'] + model['message'] = f"New message from {user['nick']} in {channel_member['label']}." + if await self.save(model): + return model + raise Exception(f"Failed to create notification: {model.errors}.") diff --git a/src/snek/service/user.py b/src/snek/service/user.py index cfcd6b8..11d7489 100644 --- a/src/snek/service/user.py +++ b/src/snek/service/user.py @@ -7,10 +7,8 @@ class UserService(BaseService): async def validate_login(self, username, password): model = await self.get(username=username) - print("FOUND USER!", model, flush=True) if not model: return False - print("AU", password, model.password.value, flush=True) if not await security.verify(password, model["password"]): return False return True @@ -19,9 +17,14 @@ class UserService(BaseService): if await self.exists(username=username): raise Exception("User already exists.") model = await self.new() + model['nick'] = username model.email.value = email model.username.value = username model.password.value = await security.hash(password) if await self.save(model): + if model: + channel = await self.services.channel.ensure_public_channel(model['uid']) + if not channel: + raise Exception("Failed to create public channel.") return model raise Exception(f"Failed to create user: {model.errors}.") diff --git a/src/snek/system/cache.py b/src/snek/system/cache.py index 5e275d9..2854e7a 100644 --- a/src/snek/system/cache.py +++ b/src/snek/system/cache.py @@ -1,7 +1,97 @@ import functools +import json +import uuid +from snek.system import security cache = functools.cache +CACHE_MAX_ITEMS_DEFAULT=5000 + +class Cache: + def __init__(self, app,max_items=CACHE_MAX_ITEMS_DEFAULT): + self.app = app + self.cache = {} + self.max_items = max_items + self.lru = [] + self.version = ((42+420+1984+1990+10+6+71+3004+7245)^1337)+4 + + async def get(self, args): + try: + self.lru.pop(self.lru.index(args)) + except: + print("Cache miss!",args,flush=True) + return None + self.lru.insert(0, args) + while(len(self.lru) > self.max_items): + self.cache.pop(self.lru[-1]) + self.lru.pop() + print("Cache hit!",args,flush=True) + return self.cache[args] + + def json_default(self, value): + #if hasattr(value, "to_json"): + # return value.to_json() + try: + return json.dumps(value.__dict__, default=str) + except: + return str(value) + + async def create_cache_key(self, args, kwargs): + return await security.hash(json.dumps({"args": args, "kwargs": kwargs}, sort_keys=True,default=self.json_default)) + + async def set(self, args, result): + is_new = not args in self.cache + self.cache[args] = result + try: + self.lru.pop(self.lru.index(args)) + except(ValueError, IndexError): + pass + self.lru.insert(0,args) + + while(len(self.lru) > self.max_items): + self.cache.pop(self.lru[-1]) + self.lru.pop() + + if is_new: + self.version += 1 + print("New version:",self.version,flush=True) + + async def delete(self, args): + if args in self.cache: + try: + self.lru.pop(self.lru.index(args)) + except IndexError: + pass + del self.cache[args] + + def async_cache(self,func): + @functools.wraps(func) + async def wrapper(*args,**kwargs): + cache_key = await self.create_cache_key(args,kwargs) + cached = await self.get(cache_key) + if cached: + return cached + result = await func(*args,**kwargs) + await self.set(cache_key,result) + return result + return wrapper + + + + def async_delete_cache(self,func): + @functools.wraps(func) + async def wrapper(*args,**kwargs): + cache_key = await self.create_cache_key(args,kwargs) + if cache_key in self.cache: + try: + self.lru.pop(self.lru.index(cache_key)) + except IndexError: + pass + del self.cache[cache_key] + return await func(*args, **kwargs) + + return wrapper + def async_cache(func): cache = {} diff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py index 489ff90..66946d8 100644 --- a/src/snek/system/mapper.py +++ b/src/snek/system/mapper.py @@ -54,7 +54,10 @@ class BaseMapper: if not kwargs.get("_limit"): kwargs["_limit"] = self.default_limit for record in self.table.find(**kwargs): - yield await self.model_class.from_record(mapper=self, record=record) + model = await self.new() + for key, value in record.items(): + model[key] = value + yield model async def delete(self, kwargs=None) -> int: if not kwargs or not isinstance(kwargs, dict): diff --git a/src/snek/system/object.py b/src/snek/system/object.py new file mode 100644 index 0000000..c6d1571 --- /dev/null +++ b/src/snek/system/object.py @@ -0,0 +1,15 @@ + + +class Object: + + def __init__(self, *args, **kwargs): + for arg in args: + if isinstance(arg,dict): + self.__dict__.update(arg) + self.__dict__.update(kwargs) + + def __getitem__(self, key): + return self.__dict__[key] + + def __setitem__(self, key, value): + self.__dict__[key] = value \ No newline at end of file diff --git a/src/snek/system/service.py b/src/snek/system/service.py index 1f9d601..942c77c 100644 --- a/src/snek/system/service.py +++ b/src/snek/system/service.py @@ -7,14 +7,23 @@ class BaseService: mapper_name: BaseMapper = None + @property + def services(self): + return self.app.services + def __init__(self, app): self.app = app + self.cache = app.cache if self.mapper_name: self.mapper = get_mapper(self.mapper_name, app=self.app) else: self.mapper = None - async def exists(self, **kwargs): + async def exists(self,uid=None, **kwargs): + if uid: + if not kwargs and await self.cache.get(uid): + return True + kwargs['uid'] = uid return await self.count(**kwargs) > 0 async def count(self, **kwargs): @@ -23,15 +32,30 @@ class BaseService: async def new(self, **kwargs): return await self.mapper.new() - async def get(self, **kwargs): - return await self.mapper.get(**kwargs) + async def get(self,uid=None, **kwargs): + if uid: + if not kwargs: + result = await self.cache.get(uid) + if result: + return result + kwargs['uid'] = uid + + result = await self.mapper.get(**kwargs) + if result: + await self.cache.set(result['uid'], result) + return result async def save(self, model: UserModel): # if model.is_valid: You Know why not - return await self.mapper.save(model) and True + if await self.mapper.save(model): + await self.cache.set(model['uid'], model) + return True + errors = await model.errors + raise Exception(f"Couldn't save model. Errors: f{errors}") async def find(self, **kwargs): - return await self.mapper.find(**kwargs) + async for model in self.mapper.find(**kwargs): + yield model async def delete(self, **kwargs): return await self.mapper.delete(**kwargs) diff --git a/src/snek/system/view.py b/src/snek/system/view.py index 1074615..bec52ed 100644 --- a/src/snek/system/view.py +++ b/src/snek/system/view.py @@ -20,8 +20,8 @@ class BaseView(web.View): def db(self): return self.app.db - async def json_response(self, data): - return web.json_response(data) + async def json_response(self, data,**kwargs): + return web.json_response(data,**kwargs) @property def session(self): diff --git a/src/snek/view/__init__.py b/src/snek/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/snek/view/login.py b/src/snek/view/login.py index 6566df9..338699a 100644 --- a/src/snek/view/login.py +++ b/src/snek/view/login.py @@ -1,18 +1,25 @@ -from snek.form.register import RegisterForm -from snek.system.view import BaseView +from snek.form.login import LoginForm +from snek.system.view import BaseFormView, BaseView +from aiohttp import web - -class LoginView(BaseView): +class LoginView(BaseFormView): + form = LoginForm async def get(self): + if self.session.get("logged_in"): + return web.HTTPFound("/web.html") + if self.request.path.endswith(".json"): + return await super().get() return await self.render_template( "login.html" - ) # web.json_response({"form": RegisterForm().to_json()}) + ) - async def post(self): - form = RegisterForm() - form.set_user_data(await self.request.post()) - print(form.is_valid()) - return await self.render_template( - "login.html", self.request - ) # web.json_response({"form": RegisterForm().to_json()}) + async def submit(self, form): + if await form.is_valid: + self.session["logged_in"] = True + self.session["username"] = form.username.value + self.session["uid"] = form.uid.value + return {"redirect_url": "/web.html"} + return {"is_valid": False} + + \ No newline at end of file diff --git a/src/snek/view/register.py b/src/snek/view/register.py index 1186959..8910cf0 100644 --- a/src/snek/view/register.py +++ b/src/snek/view/register.py @@ -1,7 +1,25 @@ -from snek.system.view import BaseView +from snek.form.register import RegisterForm +from snek.system.view import BaseFormView, BaseView +from aiohttp import web +class RegisterView(BaseFormView): + + form = RegisterForm -class RegisterView(BaseView): async def get(self): + if self.session.get("logged_in"): + return web.HTTPFound("/web.html") + if self.request.path.endswith(".json"): + return await super().get() return await self.render_template("register.html") + + async def submit(self, form): + result = await self.app.services.user.register( + form.email.value, form.username.value, form.password.value + ) + self.request.session["uid"] = result["uid"] + self.request.session["username"] = result["username"] + self.request.session["logged_in"] = True + + return {"redirect_url": "/web.html"} diff --git a/src/snek/view/status.py b/src/snek/view/status.py index add86a6..5918fa6 100644 --- a/src/snek/view/status.py +++ b/src/snek/view/status.py @@ -1,13 +1,31 @@ from snek.system.view import BaseView - +import json class StatusView(BaseView): async def get(self): + + memberships = [] + user = {} + + if self.session.get("uid"): + user = await self.app.services.user.get(uid=self.session.get("uid")) + if not user: + return await self.json_response({"error": "User not found"}, status=404) + async for model in self.app.services.channel_member.find(user_uid=self.session.get("uid"),deleted_at=None,is_banned=False): + channel = await self.app.services.channel.get(uid=model['channel_uid']) + memberships.append(dict(name=channel['label'],description=model['description'],user_uid=model['user_uid'],is_moderator=model['is_moderator'],is_read_only=model['is_read_only'],is_muted=model['is_muted'],is_banned=model['is_banned'],channel_uid=model['channel_uid'],uid=model['uid'])) + user = dict( + username=user['username'], + email=user['email'], + nick=user['nick'], + uid=user['uid'], + memberships=memberships + ) + + return await self.json_response( { - "status": "ok", - "username": self.session.get("username"), - "logged_in": self.session.get("username") and True or False, - "uid": self.session.get("uid"), + "user": user, + "cache": await self.app.cache.create_cache_key(self.app.cache.cache,None) } )