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
from types import SimpleNamespace
from aiohttp import web
from aiohttp_session import (
@ -14,16 +13,15 @@ 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
from snek.view.docs import DocsHTMLView, DocsMDView
from snek.view.index import IndexView
from snek.view.login import LoginView
from snek.view.login_form import LoginFormView
from snek.view.logout import LogoutView
from snek.view.register import RegisterView
from snek.view.register_form import RegisterFormView
from snek.view.status import StatusView
from snek.view.web import WebView
@ -53,11 +51,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 +72,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)

View File

@ -1,11 +1,22 @@
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.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):

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

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

View File

@ -1,12 +1,20 @@
import functools
from snek.service.channel import ChannelService
from snek.service.channel_member import ChannelMemberService
from snek.service.user import UserService
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):

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

View File

@ -1,7 +1,103 @@
import functools
import json
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 = 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):
cache = {}

View File

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

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

View File

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

View File

View File

@ -1,18 +1,23 @@
from snek.form.register import RegisterForm
from snek.system.view import BaseView
from aiohttp import web
from snek.form.login import LoginForm
from snek.system.view import BaseFormView
class LoginView(BaseView):
class LoginView(BaseFormView):
form = LoginForm
async def get(self):
return await self.render_template(
"login.html"
) # web.json_response({"form": RegisterForm().to_json()})
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")
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}

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):
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"}

View File

@ -3,11 +3,44 @@ from snek.system.view import BaseView
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(
{
"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(
{
"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
),
}
)