forked from rDrama/rDrama
add ping groups
parent
01040daf7c
commit
a90744145a
|
@ -30,3 +30,5 @@ from .transactions import *
|
||||||
from .sub_logs import *
|
from .sub_logs import *
|
||||||
from .media import *
|
from .media import *
|
||||||
from .push_subscriptions import *
|
from .push_subscriptions import *
|
||||||
|
from .group import *
|
||||||
|
from .group_membership import *
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Column
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.types import Integer
|
||||||
|
|
||||||
|
from files.classes import Base
|
||||||
|
from files.helpers.lazy import lazy
|
||||||
|
from files.helpers.config.const import *
|
||||||
|
|
||||||
|
from .group_membership import *
|
||||||
|
|
||||||
|
class Group(Base):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
name = Column(String, primary_key=True)
|
||||||
|
created_utc = Column(Integer)
|
||||||
|
|
||||||
|
memberships = relationship("GroupMembership", primaryjoin="GroupMembership.group_name==Group.name", order_by="GroupMembership.created_utc")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time())
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lazy
|
||||||
|
def owner(self):
|
||||||
|
return self.memberships[0].user
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lazy
|
||||||
|
def members(self):
|
||||||
|
return [x.user for x in self.memberships if x.approved_utc]
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lazy
|
||||||
|
def member_ids(self):
|
||||||
|
return [x.id for x in self.members]
|
||||||
|
|
||||||
|
@property
|
||||||
|
@lazy
|
||||||
|
def applied_ids(self):
|
||||||
|
return [x.user_id for x in self.memberships if not x.approved_utc]
|
|
@ -0,0 +1,23 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import Column, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.types import Integer, String
|
||||||
|
|
||||||
|
from files.classes import Base
|
||||||
|
|
||||||
|
class GroupMembership(Base):
|
||||||
|
__tablename__ = "group_memberships"
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
|
||||||
|
group_name = Column(String, ForeignKey("groups.name"), primary_key=True)
|
||||||
|
created_utc = Column(Integer)
|
||||||
|
approved_utc = Column(Integer)
|
||||||
|
|
||||||
|
user = relationship("User", uselist=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
if "created_utc" not in kwargs: kwargs["created_utc"] = int(time.time())
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{self.__class__.__name__}(user_id={self.user_id}, group={self.group})>"
|
|
@ -5,7 +5,7 @@ import gevent
|
||||||
from flask import g
|
from flask import g
|
||||||
from pywebpush import webpush
|
from pywebpush import webpush
|
||||||
|
|
||||||
from files.classes import Comment, Notification, PushSubscription
|
from files.classes import Comment, Notification, PushSubscription, Group
|
||||||
|
|
||||||
from .config.const import *
|
from .config.const import *
|
||||||
from .regex import *
|
from .regex import *
|
||||||
|
@ -126,10 +126,13 @@ def NOTIFY_USERS(text, v):
|
||||||
if word in text and id not in notify_users:
|
if word in text and id not in notify_users:
|
||||||
notify_users.add(id)
|
notify_users.add(id)
|
||||||
|
|
||||||
if v.id != AEVANN_ID and '!biofoids' in text and SITE == 'rdrama.net':
|
if FEATURES['PING_GROUPS']:
|
||||||
if v.id not in BIOFOIDS:
|
for i in group_mention_regex.finditer(text):
|
||||||
abort(403, "Only members of the ping group can ping it!")
|
group = g.db.get(Group, i.group(2))
|
||||||
notify_users.update(BIOFOIDS)
|
if group:
|
||||||
|
if v.id not in group.member_ids:
|
||||||
|
abort(403, "Only members of the ping group can ping it!")
|
||||||
|
notify_users.update(group.member_ids)
|
||||||
|
|
||||||
names = set(m.group(2) for m in mention_regex.finditer(text))
|
names = set(m.group(2) for m in mention_regex.finditer(text))
|
||||||
for user in get_users(names, graceful=True):
|
for user in get_users(names, graceful=True):
|
||||||
|
|
|
@ -517,6 +517,7 @@ FEATURES = {
|
||||||
'PATRON_ICONS': False,
|
'PATRON_ICONS': False,
|
||||||
'ASSET_SUBMISSIONS': False,
|
'ASSET_SUBMISSIONS': False,
|
||||||
'NSFW_MARKING': True,
|
'NSFW_MARKING': True,
|
||||||
|
'PING_GROUPS': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
WERKZEUG_ERROR_DESCRIPTIONS = {
|
WERKZEUG_ERROR_DESCRIPTIONS = {
|
||||||
|
@ -621,6 +622,7 @@ HOLE_NAME = 'hole'
|
||||||
HOLE_STYLE_FLAIR = False
|
HOLE_STYLE_FLAIR = False
|
||||||
HOLE_REQUIRED = False
|
HOLE_REQUIRED = False
|
||||||
HOLE_COST = 0
|
HOLE_COST = 0
|
||||||
|
GROUP_COST = 10000
|
||||||
HOLE_INACTIVITY_DELETION = False
|
HOLE_INACTIVITY_DELETION = False
|
||||||
|
|
||||||
PRIVILEGED_USER_BOTS = ()
|
PRIVILEGED_USER_BOTS = ()
|
||||||
|
@ -1043,5 +1045,3 @@ AUDIO_FORMATS = ('mp3','wav','ogg','aac','m4a','flac')
|
||||||
if not IS_LOCALHOST and SECRET_KEY == DEFAULT_CONFIG_VALUE:
|
if not IS_LOCALHOST and SECRET_KEY == DEFAULT_CONFIG_VALUE:
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
warn("Secret key is the default value! Please change it to a secure random number. Thanks <3", RuntimeWarning)
|
warn("Secret key is the default value! Please change it to a secure random number. Thanks <3", RuntimeWarning)
|
||||||
|
|
||||||
BIOFOIDS = {2654,12966,10682,11375,18523,2432,1054,2054,15869,18339,2113,114,35}
|
|
||||||
|
|
|
@ -7,7 +7,9 @@ from .config.const import *
|
||||||
|
|
||||||
valid_username_chars = 'a-zA-Z0-9_\-'
|
valid_username_chars = 'a-zA-Z0-9_\-'
|
||||||
valid_username_regex = re.compile("^[a-zA-Z0-9_\-]{3,25}$", flags=re.A)
|
valid_username_regex = re.compile("^[a-zA-Z0-9_\-]{3,25}$", flags=re.A)
|
||||||
mention_regex = re.compile('(^|\s|>)@(([a-zA-Z0-9_\-]){1,30})(?![^<]*<\/(code|pre|a)>)', flags=re.A)
|
mention_regex = re.compile('(^|\s|>)@([a-zA-Z0-9_\-]{1,30})(?![^<]*<\/(code|pre|a)>)', flags=re.A)
|
||||||
|
|
||||||
|
group_mention_regex = re.compile('(^|\s|>)!([a-z0-9_\-]{3,25})(?![^<]*<\/(code|pre|a)>)', flags=re.A)
|
||||||
|
|
||||||
valid_password_regex = re.compile("^.{8,100}$", flags=re.A)
|
valid_password_regex = re.compile("^.{8,100}$", flags=re.A)
|
||||||
|
|
||||||
|
|
|
@ -47,3 +47,5 @@ if FEATURES['ASSET_SUBMISSIONS']:
|
||||||
from .asset_submissions import *
|
from .asset_submissions import *
|
||||||
from .special import *
|
from .special import *
|
||||||
from .push_notifs import *
|
from .push_notifs import *
|
||||||
|
if FEATURES['PING_GROUPS']:
|
||||||
|
from .groups import *
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
from files.classes import *
|
||||||
|
from files.helpers.alerts import *
|
||||||
|
from files.helpers.regex import *
|
||||||
|
from files.helpers.get import *
|
||||||
|
|
||||||
|
from files.routes.wrappers import *
|
||||||
|
|
||||||
|
from files.__main__ import app, limiter
|
||||||
|
|
||||||
|
@app.get("/ping_groups")
|
||||||
|
@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID)
|
||||||
|
@auth_required
|
||||||
|
def ping_groups(v:User):
|
||||||
|
groups = g.db.query(Group).order_by(Group.created_utc).all()
|
||||||
|
return render_template('groups.html', v=v, groups=groups, cost=GROUP_COST, msg=get_msg(), error=get_error())
|
||||||
|
|
||||||
|
@app.post("/create_group")
|
||||||
|
@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID)
|
||||||
|
@is_not_permabanned
|
||||||
|
def create_group(v):
|
||||||
|
name = request.values.get('name')
|
||||||
|
if not name: abort(400)
|
||||||
|
name = name.strip().lower()
|
||||||
|
|
||||||
|
if not valid_sub_regex.fullmatch(name):
|
||||||
|
return redirect(f"/ping_groups?error=Name does not match the required format!")
|
||||||
|
|
||||||
|
group = g.db.get(Group, name)
|
||||||
|
if not group:
|
||||||
|
if not v.charge_account('coins', GROUP_COST):
|
||||||
|
return redirect(f"/ping_groups?error=You don't have enough coins!")
|
||||||
|
|
||||||
|
g.db.add(v)
|
||||||
|
if v.shadowbanned: abort(500)
|
||||||
|
|
||||||
|
group = Group(name=name)
|
||||||
|
g.db.add(group)
|
||||||
|
g.db.flush()
|
||||||
|
|
||||||
|
group_membership = GroupMembership(
|
||||||
|
user_id=v.id,
|
||||||
|
group_name=group.name,
|
||||||
|
created_utc=time.time(),
|
||||||
|
approved_utc=time.time()
|
||||||
|
)
|
||||||
|
g.db.add(group_membership)
|
||||||
|
|
||||||
|
admins = [x[0] for x in g.db.query(User.id).filter(User.admin_level >= PERMS['NOTIFICATIONS_HOLE_CREATION'], User.id != v.id).all()]
|
||||||
|
for admin in admins:
|
||||||
|
send_repeatable_notification(admin, f":!marseyparty: !{group} has been created by @{v.username} :marseyparty:")
|
||||||
|
|
||||||
|
return redirect(f'/ping_groups?msg=!{group} created successfully!')
|
||||||
|
|
||||||
|
@app.post("/!<group_name>/join")
|
||||||
|
@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID)
|
||||||
|
@auth_required
|
||||||
|
def join_group(v:User, group_name):
|
||||||
|
group = g.db.get(Group, group_name)
|
||||||
|
if not group: abort(404)
|
||||||
|
existing = g.db.query(GroupMembership).filter_by(user_id=v.id, group_name=group_name).one_or_none()
|
||||||
|
if not existing:
|
||||||
|
join = GroupMembership(user_id=v.id, group_name=group_name)
|
||||||
|
g.db.add(join)
|
||||||
|
send_repeatable_notification(group.owner.id, f"@{v.username} has applied to join !{group}. You can approve or reject the application [here](/!{group}/applications).")
|
||||||
|
|
||||||
|
return redirect(f"/ping_groups?msg=Application submitted to !{group}'s owner (@{group.owner.username}) successfully!")
|
||||||
|
|
||||||
|
@app.get("/!<group_name>/applications")
|
||||||
|
@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID)
|
||||||
|
@auth_required
|
||||||
|
def applications(v:User, group_name):
|
||||||
|
group = g.db.get(Group, group_name)
|
||||||
|
if not group: abort(404)
|
||||||
|
applications = g.db.query(GroupMembership).filter_by(group_name=group_name, approved_utc=None).order_by(GroupMembership.created_utc.desc()).all()
|
||||||
|
return render_template('group_applications.html', v=v, group=group, applications=applications, msg=get_msg())
|
||||||
|
|
||||||
|
@app.post("/!<group_name>/<user_id>/approve")
|
||||||
|
@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID)
|
||||||
|
@auth_required
|
||||||
|
def group_approve(v:User, group_name, user_id):
|
||||||
|
group = g.db.get(Group, group_name)
|
||||||
|
if not group: abort(404)
|
||||||
|
|
||||||
|
if v.id != group.owner.id:
|
||||||
|
abort(403, f"Only the group owner (@{group.owner.username}) can approve applications!")
|
||||||
|
|
||||||
|
application = g.db.query(GroupMembership).filter_by(user_id=user_id, group_name=group.name).one_or_none()
|
||||||
|
if not application:
|
||||||
|
abort(404, "There is no application to approve!")
|
||||||
|
|
||||||
|
if not application.approved_utc:
|
||||||
|
application.approved_utc = time.time()
|
||||||
|
g.db.add(application)
|
||||||
|
send_repeatable_notification(application.user_id, f"@{v.username} (!{group}'s owner) has approved your application!")
|
||||||
|
|
||||||
|
return redirect(f'/!{group}/applications?msg=@{application.user.username} has been approved successfully!')
|
||||||
|
|
||||||
|
@app.post("/!<group_name>/<user_id>/reject")
|
||||||
|
@limiter.limit(DEFAULT_RATELIMIT, key_func=get_ID)
|
||||||
|
@auth_required
|
||||||
|
def group_reject(v:User, group_name, user_id):
|
||||||
|
group = g.db.get(Group, group_name)
|
||||||
|
if not group: abort(404)
|
||||||
|
|
||||||
|
if v.id != group.owner.id:
|
||||||
|
abort(403, f"Only the group owner (@{group.owner.username}) can reject applications!")
|
||||||
|
|
||||||
|
application = g.db.query(GroupMembership).filter_by(user_id=user_id, group_name=group.name).one_or_none()
|
||||||
|
if not application:
|
||||||
|
abort(404, "There is no application to reject!")
|
||||||
|
|
||||||
|
g.db.delete(application)
|
||||||
|
send_repeatable_notification(application.user_id, f"@{v.username} (!{group}'s owner) has rejected your application!")
|
||||||
|
|
||||||
|
return redirect(f'/!{group}/applications?msg=@{application.user.username} has been rejected successfully!')
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends "default.html" %}
|
||||||
|
{% block pagetitle %}!{{group}} Applications{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% if error %}{{macros.alert(error, true)}}{% endif %}
|
||||||
|
{% if msg %}{{macros.alert(msg, false)}}{% endif %}
|
||||||
|
<h5 class="my-3">!{{group}} Applications</h5>
|
||||||
|
<div class="overflow-x-auto mt-1"><table class="table table-striped mb-5">
|
||||||
|
<thead class="bg-primary text-white">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Applied on</th>
|
||||||
|
{% if v.id == group.owner.id %}
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for application in applications %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% with user=application.user %}
|
||||||
|
{% include "user_in_table.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</td>
|
||||||
|
<td data-time="{{application.created_utc}}"></td>
|
||||||
|
{% if v.id == group.owner.id %}
|
||||||
|
<td>
|
||||||
|
<form class="d-inline" action="/!{{group}}/{{application.user_id}}/approve" method="post" data-nonce="{{g.nonce}}">
|
||||||
|
<input hidden name="formkey" value="{{v|formkey}}">
|
||||||
|
<input autocomplete="off" class="btn btn-success ml-auto" type="submit" data-nonce="{{g.nonce}}" data-onclick="disable(this)" value="Approve">
|
||||||
|
</form>
|
||||||
|
<form class="d-inline ml-3" action="/!{{group}}/{{application.user_id}}/reject" method="post" data-nonce="{{g.nonce}}">
|
||||||
|
<input hidden name="formkey" value="{{v|formkey}}">
|
||||||
|
<input autocomplete="off" class="btn btn-danger ml-auto" type="submit" data-nonce="{{g.nonce}}" data-onclick="disable(this)" value="Reject">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,76 @@
|
||||||
|
{% extends "default.html" %}
|
||||||
|
{% block pagetitle %}Ping Groups{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{% if error %}{{macros.alert(error, true)}}{% endif %}
|
||||||
|
{% if msg %}{{macros.alert(msg, false)}}{% endif %}
|
||||||
|
<div class="px-3">
|
||||||
|
<h3 class="my-3">Ping Groups</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-striped mb-5">
|
||||||
|
<thead class="bg-primary text-white">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Members</th>
|
||||||
|
<th>Created on</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{% for group in groups %}
|
||||||
|
<tr>
|
||||||
|
<td>{{loop.index}}</td>
|
||||||
|
<td>{{group.name}}</td>
|
||||||
|
<td>
|
||||||
|
{% for user in group.members %}
|
||||||
|
<span class="mr-2">
|
||||||
|
{% include "user_in_table.html" %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td data-time="{{group.created_utc}}"></td>
|
||||||
|
<td>
|
||||||
|
{% if v.id in group.applied_ids %}
|
||||||
|
<a class="btn btn-primary" href="/!{{group.name}}/applications">Application pending</a>
|
||||||
|
{% elif v.id not in group.member_ids %}
|
||||||
|
<form action="/!{{group}}/join" method="post" data-nonce="{{g.nonce}}">
|
||||||
|
<input hidden name="formkey" value="{{v|formkey}}">
|
||||||
|
<input autocomplete="off" class="btn btn-primary ml-auto" type="submit" data-nonce="{{g.nonce}}" data-onclick="disable(this)" value="Apply to Join">
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-primary" href="/!{{group.name}}/applications">Applications</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-5 mb-3">Create Ping Group</h3>
|
||||||
|
<form class="mt-3" action="/create_group" method="post">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row mb-4 pb-6">
|
||||||
|
<div class="col col-md-6 px-0 py-3 py-md-0">
|
||||||
|
<div class="body">
|
||||||
|
<input hidden name="formkey" value="{{v|formkey}}">
|
||||||
|
<label for="title">Group Name</label>
|
||||||
|
<input minlength="3" maxlength="25" pattern='[a-zA-Z0-9_\-]*' class="form-control" type="text" name="name" required>
|
||||||
|
<small class="form-text text-muted">3-25 characters, including letters, numbers, _ , and -</small>
|
||||||
|
<div class="footer">
|
||||||
|
<div class="d-flex">
|
||||||
|
{% if error %}
|
||||||
|
<p class="mb-0">
|
||||||
|
<span class="text-danger text-small" style="vertical-align: sub;">{{error}}</span>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn btn-primary ml-auto" {% if v.coins < cost %}disabled{% endif %}>Create Group</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 mr-1" style="float: right"><b>Cost</b>: {{cost}} coins</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -7,6 +7,12 @@
|
||||||
|
|
||||||
{%- if SITE_NAME == 'rDrama' -%}
|
{%- if SITE_NAME == 'rDrama' -%}
|
||||||
{%- do MEGATHREAD_INDEX.extend([
|
{%- do MEGATHREAD_INDEX.extend([
|
||||||
|
(
|
||||||
|
'Ping Groups',
|
||||||
|
'List of ping groups',
|
||||||
|
'fa-users', '#dc3545',
|
||||||
|
'/ping_groups',
|
||||||
|
),
|
||||||
(
|
(
|
||||||
'Bugs / Suggestions',
|
'Bugs / Suggestions',
|
||||||
'Something broken? Improvements?',
|
'Something broken? Improvements?',
|
||||||
|
|
|
@ -52,7 +52,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<a id="sidebar--directory--btn" class="btn btn-primary btn-block mb-3" href="/directory">
|
<a id="sidebar--directory--btn" class="btn btn-primary btn-block mb-3" href="/directory">
|
||||||
<span id="sidebar--directory--head">DIRECTORY</span>
|
<span id="sidebar--directory--head">DIRECTORY</span>
|
||||||
<span id="sidebar--directory--subhead">Submit Marseys & Art | Info Megathreads</span>
|
<span id="sidebar--directory--subhead">Ping Groups | Submit Marseys & Art | Info Megathreads</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-primary btn-block mb-3" href="/holes">BROWSE {{HOLE_NAME|upper}}S</a>
|
<a class="btn btn-primary btn-block mb-3" href="/holes">BROWSE {{HOLE_NAME|upper}}S</a>
|
||||||
{% if v and v.can_create_hole -%}
|
{% if v and v.can_create_hole -%}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{%- include 'admin/shadowbanned_tooltip.html' -%}
|
{%- include 'admin/shadowbanned_tooltip.html' -%}
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<a data-sort-key="{{user.username.lower()}}" style="color:#{{user.name_color}};font-weight:bold" href="/@{{user.username}}">
|
<a data-sort-key="{{user.username.lower()}}" style="color:#{{user.name_color}};font-weight:bold" href="/@{{user.username}}">
|
||||||
<div class="profile-pic-20-wrapper mb-2 mr-1 float-left">
|
<div class="profile-pic-20-wrapper mb-2 {% if request.path != '/ping_groups' %}mr-1 float-left{% endif %}">
|
||||||
<img loading="lazy" src="{{user.profile_url}}" class="pp20">
|
<img loading="lazy" src="{{user.profile_url}}" class="pp20">
|
||||||
{% if user.hat_active(v)[0] -%}
|
{% if user.hat_active(v)[0] -%}
|
||||||
<img class="profile-pic-20-hat hat" loading="lazy" src="{{user.hat_active(v)[0]}}?h=7" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{user.hat_active(v)[1]}}">
|
<img class="profile-pic-20-hat hat" loading="lazy" src="{{user.hat_active(v)[0]}}?h=7" data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{user.hat_active(v)[1]}}">
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
create table groups (
|
||||||
|
name character varying(25) primary key,
|
||||||
|
created_utc integer not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create index groups_index on groups using btree (created_utc asc);
|
||||||
|
|
||||||
|
create table group_memberships (
|
||||||
|
user_id integer not null,
|
||||||
|
group_name varchar(25) not null,
|
||||||
|
created_utc integer not null,
|
||||||
|
approved_utc integer
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table only group_memberships
|
||||||
|
add constraint group_memberships_pkey primary key (user_id, group_name);
|
||||||
|
|
||||||
|
alter table only group_memberships
|
||||||
|
add constraint group_memberships_user_fkey foreign key (user_id) references public.users(id);
|
||||||
|
|
||||||
|
alter table only group_memberships
|
||||||
|
add constraint group_memberships_group_fkey foreign key (group_name) references public.groups(name);
|
Loading…
Reference in New Issue