Compare commits

...

2 Commits

Author SHA1 Message Date
f25feeeca3 Formatting. 2025-01-25 22:28:33 +01:00
b4f9ff2c62 Mappers and models. 2025-01-25 22:24:44 +01:00
27 changed files with 471 additions and 43 deletions

View File

@ -1,5 +1,4 @@
import pathlib import pathlib
from types import SimpleNamespace
from aiohttp import web from aiohttp import web
from aiohttp_session import ( from aiohttp_session import (
@ -14,16 +13,15 @@ from snek.docs.app import Application as DocsApplication
from snek.mapper import get_mappers from snek.mapper import get_mappers
from snek.service import get_services from snek.service import get_services
from snek.system import http from snek.system import http
from snek.system.cache import Cache
from snek.system.markdown import MarkdownExtension from snek.system.markdown import MarkdownExtension
from snek.system.middleware import cors_middleware from snek.system.middleware import cors_middleware
from snek.view.about import AboutHTMLView, AboutMDView from snek.view.about import AboutHTMLView, AboutMDView
from snek.view.docs import DocsHTMLView, DocsMDView from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.index import IndexView from snek.view.index import IndexView
from snek.view.login import LoginView from snek.view.login import LoginView
from snek.view.login_form import LoginFormView
from snek.view.logout import LogoutView from snek.view.logout import LogoutView
from snek.view.register import RegisterView from snek.view.register import RegisterView
from snek.view.register_form import RegisterFormView
from snek.view.status import StatusView from snek.view.status import StatusView
from snek.view.web import WebView from snek.view.web import WebView
@ -53,11 +51,9 @@ class Application(BaseApplication):
self._middlewares.append(session_middleware) self._middlewares.append(session_middleware)
self.jinja2_env.add_extension(MarkdownExtension) self.jinja2_env.add_extension(MarkdownExtension)
self.setup_router() self.setup_router()
self.setup_services() self.cache = Cache(self)
self.services = get_services(app=self)
def setup_services(self): self.mappers = get_mappers(app=self)
self.services = SimpleNamespace(**get_services(app=self))
self.mappers = SimpleNamespace(**get_mappers(app=self))
def setup_router(self): def setup_router(self):
self.router.add_get("/", IndexView) self.router.add_get("/", IndexView)
@ -76,9 +72,9 @@ class Application(BaseApplication):
self.router.add_view("/status.json", StatusView) self.router.add_view("/status.json", StatusView)
self.router.add_view("/web.html", WebView) self.router.add_view("/web.html", WebView)
self.router.add_view("/login.html", LoginView) 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.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-get", self.handle_http_get)
self.router.add_get("/http-photo", self.handle_http_photo) self.router.add_get("/http-photo", self.handle_http_photo)

View File

@ -1,11 +1,22 @@
import functools import functools
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.mapper.user import UserMapper
from snek.system.object import Object
@functools.cache @functools.cache
def get_mappers(app=None): 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): def get_mapper(name, app=None):

View File

@ -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

View File

@ -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

View File

@ -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"

View File

View File

@ -1,11 +1,24 @@
import functools 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.model.user import UserModel
from snek.system.object import Object
@functools.cache @functools.cache
def get_models(): def get_models():
return {"user": UserModel} return Object(
**{
"user": UserModel,
"channel_member": ChannelMemberModel,
"channel": ChannelModel,
"channel_message": ChannelMessageModel,
}
)
def get_model(name): def get_model(name):

11
src/snek/model/channel.py Normal file
View File

@ -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)

View File

@ -0,0 +1,15 @@
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)

View File

@ -0,0 +1,7 @@
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)

View File

@ -0,0 +1,9 @@
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)

View File

@ -10,6 +10,13 @@ class UserModel(BaseModel):
max_length=20, max_length=20,
regex=r"^[a-zA-Z0-9_]+$", 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( email = ModelField(
name="email", name="email",
required=False, required=False,

View File

@ -1,12 +1,20 @@
import functools import functools
from snek.service.channel import ChannelService
from snek.service.channel_member import ChannelMemberService
from snek.service.user import UserService from snek.service.user import UserService
from snek.system.object import Object
@functools.cache @functools.cache
def get_services(app): def get_services(app):
return Object(
return {"user": UserService(app=app)} **{
"user": UserService(app=app),
"channel_member": ChannelMemberService(app=app),
"channel": ChannelService(app=app),
}
)
def get_service(name, app=None): def get_service(name, app=None):

View File

@ -0,0 +1,48 @@
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

View File

@ -0,0 +1,33 @@
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}.")

View File

@ -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}.")

View File

@ -0,0 +1,37 @@
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}.")

View File

@ -7,10 +7,8 @@ class UserService(BaseService):
async def validate_login(self, username, password): async def validate_login(self, username, password):
model = await self.get(username=username) model = await self.get(username=username)
print("FOUND USER!", model, flush=True)
if not model: if not model:
return False return False
print("AU", password, model.password.value, flush=True)
if not await security.verify(password, model["password"]): if not await security.verify(password, model["password"]):
return False return False
return True return True
@ -19,9 +17,16 @@ class UserService(BaseService):
if await self.exists(username=username): if await self.exists(username=username):
raise Exception("User already exists.") raise Exception("User already exists.")
model = await self.new() model = await self.new()
model["nick"] = username
model.email.value = email model.email.value = email
model.username.value = username model.username.value = username
model.password.value = await security.hash(password) model.password.value = await security.hash(password)
if await self.save(model): 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 return model
raise Exception(f"Failed to create user: {model.errors}.") raise Exception(f"Failed to create user: {model.errors}.")

View File

@ -1,7 +1,103 @@
import functools import functools
import json
from snek.system import security
cache = functools.cache 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 = args not 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): def async_cache(func):
cache = {} cache = {}

View File

@ -54,7 +54,10 @@ class BaseMapper:
if not kwargs.get("_limit"): if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_limit kwargs["_limit"] = self.default_limit
for record in self.table.find(**kwargs): 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: async def delete(self, kwargs=None) -> int:
if not kwargs or not isinstance(kwargs, dict): if not kwargs or not isinstance(kwargs, dict):

13
src/snek/system/object.py Normal file
View File

@ -0,0 +1,13 @@
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

View File

@ -7,14 +7,23 @@ class BaseService:
mapper_name: BaseMapper = None mapper_name: BaseMapper = None
@property
def services(self):
return self.app.services
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.cache = app.cache
if self.mapper_name: if self.mapper_name:
self.mapper = get_mapper(self.mapper_name, app=self.app) self.mapper = get_mapper(self.mapper_name, app=self.app)
else: else:
self.mapper = None 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 return await self.count(**kwargs) > 0
async def count(self, **kwargs): async def count(self, **kwargs):
@ -23,15 +32,30 @@ class BaseService:
async def new(self, **kwargs): async def new(self, **kwargs):
return await self.mapper.new() return await self.mapper.new()
async def get(self, **kwargs): async def get(self, uid=None, **kwargs):
return await self.mapper.get(**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): async def save(self, model: UserModel):
# if model.is_valid: You Know why not # 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): 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): async def delete(self, **kwargs):
return await self.mapper.delete(**kwargs) return await self.mapper.delete(**kwargs)

View File

@ -20,8 +20,8 @@ class BaseView(web.View):
def db(self): def db(self):
return self.app.db return self.app.db
async def json_response(self, data): async def json_response(self, data, **kwargs):
return web.json_response(data) return web.json_response(data, **kwargs)
@property @property
def session(self): def session(self):

View File

View File

@ -1,18 +1,23 @@
from snek.form.register import RegisterForm from aiohttp import web
from snek.system.view import BaseView
from snek.form.login import LoginForm
from snek.system.view import BaseFormView
class LoginView(BaseView): class LoginView(BaseFormView):
form = LoginForm
async def get(self): async def get(self):
return await self.render_template( if self.session.get("logged_in"):
"login.html" return web.HTTPFound("/web.html")
) # web.json_response({"form": RegisterForm().to_json()}) if self.request.path.endswith(".json"):
return await super().get()
return await self.render_template("login.html")
async def post(self): async def submit(self, form):
form = RegisterForm() if await form.is_valid:
form.set_user_data(await self.request.post()) self.session["logged_in"] = True
print(form.is_valid()) self.session["username"] = form.username.value
return await self.render_template( self.session["uid"] = form.uid.value
"login.html", self.request return {"redirect_url": "/web.html"}
) # web.json_response({"form": RegisterForm().to_json()}) return {"is_valid": False}

View File

@ -1,7 +1,26 @@
from snek.system.view import BaseView from aiohttp import web
from snek.form.register import RegisterForm
from snek.system.view import BaseFormView
class RegisterView(BaseView): class RegisterView(BaseFormView):
form = RegisterForm
async def get(self): 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") 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"}

View File

@ -3,11 +3,44 @@ from snek.system.view import BaseView
class StatusView(BaseView): class StatusView(BaseView):
async def get(self): 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(
{
"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 = {
"username": user["username"],
"email": user["email"],
"nick": user["nick"],
"uid": user["uid"],
"memberships": memberships,
}
return await self.json_response( return await self.json_response(
{ {
"status": "ok", "user": user,
"username": self.session.get("username"), "cache": await self.app.cache.create_cache_key(
"logged_in": self.session.get("username") and True or False, self.app.cache.cache, None
"uid": self.session.get("uid"), ),
} }
) )