Merge branch 'implement-shop-user-roles' into 'master'

shop user roles: owners, editors, members
shop user roles: owners, editors, members

Huge pull requests.

In this PR we implement role based authorization by extending the "user shop" table to include a new field for tracking a rudimentary role id integer.

For now the integers are from 0,1,2

0 == owner (root) 1 == editor (virtual assistant) 2 == member (viewer)

For now the member role doesn't provide any additional behavior but could be added to a future enhancement.

We have now separated out privileged views into requiring the user to be part of the owner role or editor role depending on the route.

We have created a new route called shop_users & it works for adding or removing new users into the active shop. As such for example, only users with owner access may access the new shop_users view and as such only they can add or remove users from the shop.
This commit is contained in:
Russell Ballestrini 2022-06-28 17:28:31 +00:00
commit be73a16228
16 changed files with 402 additions and 105 deletions

View file

@ -17,6 +17,8 @@ from make_post_sell.lib.mail_messages import (
PURCHASE_1_HTML,
SALE_1_TEXT,
SALE_1_HTML,
INVITE_1_TEXT,
INVITE_1_HTML,
)
@ -33,7 +35,7 @@ def send_verification_digits_to_email(request, to_email, raw_digits):
raw_digits:
the raw (unencrypted) digits the user may use to verify & authenticate.
"""
subject = "Verification Code"
subject = "Verification Code | {}".format(raw_digits)
message_text = WELCOME_1_TEXT.format(raw_digits)
message_html = WELCOME_1_HTML.format(subject, raw_digits)
send_pyramid_email(request, to_email, subject, message_text, message_html)
@ -126,3 +128,26 @@ def send_sale_email(request, shop, products, total_cost):
# send a separate email for each shop owner.
for user in shop.users:
send_pyramid_email(request, user.email, subject, message_text, message_html)
def send_invite_email(request, to_email, user, shop):
"""
Send an email to all shop owners regarding the sale.
request
the request (of the successful log in attempt)
to_email
the email address to send the shop invitation to.
user
the user who sent the invite.
shop
the shop the invitation is for.
"""
subject = "You have been invited to {}".format(shop.name)
join_link = request.route_url("join-or-log-in")
message_text = INVITE_1_TEXT.format(user.email, shop.name, join_link)
message_html = INVITE_1_HTML.format(subject, user.email, shop.name, join_link)
send_pyramid_email(request, to_email, subject, message_text, message_html)

View file

@ -1,11 +1,10 @@
WELCOME_1_TEXT = """
Hey there!
Here is the verification code you requested:
\n{0}\n
Type code into the challenge input field:
Here is the verification code you requested.
Type code into the challenge input field.
"""
WELCOME_1_HTML = """
@ -17,13 +16,10 @@ WELCOME_1_HTML = """
<body>
<h2>Hey there!</h2>
<p>
Here is the verification code you requested:
</p>
<p><b>{1}</b></p>
<p>
Here is the verification code you requested.
Type code into the challenge input field.
</p>
@ -146,3 +142,60 @@ SALE_1_HTML = """
</body>
</html>
"""
# 0: user.email
# 1: shop.name,
# 2: log in link.
INVITE_1_TEXT = """
Hey There!
{0} has invited you to join the {1} shop on Make Post Sell.
Click this link to log in.
{2}
Make Post Sell is a lightweight, open-source, public domain, software as a service ecommerce web application.
We hope you like our ethical approach to doing business online.
Thanks for sharing! <3
"""
# 0: subject,
# 1: user.email
# 2: shop.name,
# 3: log in link.
INVITE_1_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>Hey There!</h2>
<p>
{1} has invited you to join {2} on Make Post Sell.
</p>
<p>
<a href="{3}">click this link to log in to the shop.</a>
</p>
<p>
Make Post Sell is a lightweight, open-source, public domain, software as a service ecommerce web application.
We hope you like our ethical approach to doing business online.
</p>
<p>
Thanks for sharing! <3
</p>
</body>
</html>
"""

View file

@ -113,16 +113,30 @@ class Shop(RBase, Base):
UserShop.user == user,
).one_or_none()
def add_user_to_shop(self, user, default=None):
def add_user_to_shop(self, user, role_id=0):
"""Add User object to UserShop."""
us = self.get_usershop_for_user(user)
if us is None:
us = UserShop(user=user, shop=self)
us = UserShop(user=user, shop=self, role_id=role_id)
self.shop_users.append(us)
if default != None:
us.default = default
return us
@property
def owners(self):
"""Returns a list of user objects who have the owner role on this shop."""
# us is a user_shop object.
return [us.user for us in self.shop_users if us.is_owner]
@property
def editors(self):
"""Returns a list of user objects who have the editor role on this shop."""
return self.owners + [us.user for us in self.shop_users if us.is_editor]
@property
def members(self):
"""Returns a list of user objects who have the member role on this shop."""
return self.editors + [us.user for us in self.shop_users if us.is_member]
@property
def slug(self):
"""return slug from name"""

View file

@ -175,7 +175,7 @@ class User(RBase, Base):
def can_edit_product(self, product):
"""Return True or False"""
if product and self in product.shop.users:
if product and self in product.shop.editors:
return True
return False
@ -198,7 +198,7 @@ class User(RBase, Base):
return not self.can_download_product(product)
def can_edit_shop(self, shop):
if shop and self in shop.users:
if shop and self in shop.editors:
return True
return False

View file

@ -1,11 +1,16 @@
import uuid
from sqlalchemy import Column, Boolean, BigInteger
from sqlalchemy import Column, Integer, BigInteger
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
from sqlalchemy.orm import relationship, backref
role_id_to_name = {
0: "owner", # root.
1: "editor", # virtual assistant.
2: "member", # viewer.
}
class UserShop(RBase, Base):
"""
@ -17,8 +22,7 @@ class UserShop(RBase, Base):
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
# a user may set a default shop if they have more than 1.
default = Column(Boolean, default=False)
role_id = Column(Integer, nullable=False, default=0)
user = relationship(
argument="User", backref=backref("user_shops", cascade="all, delete-orphan")
@ -28,8 +32,24 @@ class UserShop(RBase, Base):
argument="Shop", backref=backref("shop_users", cascade="all, delete-orphan")
)
def __init__(self, user=None, shop=None):
def __init__(self, user=None, shop=None, role_id=0):
self.id = uuid.uuid1()
self.user = user
self.shop = shop
self.role_id = role_id
self.created_timestamp = now_timestamp()
def role_name(self):
return role_id_to_name[self.role_id]
@property
def is_owner(self):
return self.role_id == 0
@property
def is_editor(self):
return self.role_id == 1
@property
def is_member(self):
return self.role_id == 2

View file

@ -82,6 +82,8 @@ def includeme(config):
return get_shop_by_id(request.dbsession, shop_id)
elif request.product:
return request.product.shop
elif request.user and request.user.active_shop:
return request.user.active_shop
else:
return get_shop_by_domain_name(request.dbsession, request.domain)

View file

@ -72,6 +72,9 @@ def includeme(config):
config.add_route("shop_settings", "/s/{shop_id}/settings")
config.add_route("shop_users", "/s/{shop_id}/users")
config.add_route("shop_user_remove", "/s/{shop_id}/remove-user")
config.add_route("shop_terms_of_service", "/s/terms")
config.add_route("shop_terms_of_service2", "/s/{shop_id}/terms")
config.add_route("shop_edit_terms_of_service", "/s/{shop_id}/terms/edit")

View file

@ -0,0 +1,30 @@
"""role id to user shop table
Revision ID: 03292288a132
Revises: e6ac162d096f
Create Date: 2022-06-27 12:31:50.885273
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03292288a132'
down_revision = 'e6ac162d096f'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_user_shop', sa.Column('role_id', sa.Integer(), nullable=False, server_default="0"))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_user_shop', 'role_id')
# ### end Alembic commands ###

View file

@ -20,6 +20,10 @@
<br/>
<a href="/s/{{ request.user.active_shop.id }}/coupons" class="product-edit-button mps-button">&nbsp View Coupons</a>
<br/>
<br/>
<a href="/s/{{ request.user.active_shop.id }}/users" class="product-edit-button mps-button">&#128100; &nbsp Shop Users</a>
<br/>
<br/>
<a href="/s/{{ request.user.active_shop.id }}/settings" class="product-edit-button mps-button">&#9881; &nbsp Shop Settings</a>

View file

@ -274,7 +274,7 @@
<h3>Maintenance Mode 🏗️</h3>
<input type="checkbox" name="maint-mode-checkbox" id="maint-mode-checkbox" {% if maint_mode %}checked{% endif %}></input>
<input type="checkbox" name="maint-mode-checkbox" id="maint-mode-checkbox" {% if request.shop.maint_mode %}checked{% endif %}></input>
<b>If checked, enabled maintenance mode.</b>
<br />

View file

@ -0,0 +1,105 @@
{% extends "base.j2" -%}
{% block content -%}
<section class="one-column">
<section class="shop-settings well">
<h3>Invite</h3>
<form method="post" action="/s/{{ request.shop.id }}/users" onsubmit="submit.disabled = true; return true;">
<label for="add_user">Invite a new user to this shop.</label>
<input
name = "email-to-invite"
type = "text"
id = "email-to-invite"
class = "mps-shop-name"
value = ""
placeholder = "Email address"
required
/>
<style>
label.role_id_radio {
display: inline;
}
</style>
<fieldset>
<legend>role</legend>
<input type="radio" name="role-id" id="role_id_0" value="0" %} />
<label class="role_id_radio" for="role_id_0">owner</label>
<input type="radio" name="role-id" id="role_id_1" value="1" />
<label class="role_id_radio" for="role_id_1">editor</label>
<input type="radio" name="role-id" id="role_id_2" value="2" />
<label class="role_id_radio" for="role_id_2">member</label>
</fieldset>
<br />
<input type="submit" name="submit" class="mps-submit" value="Send Invite" />
</form>
<br />
<h3>owners</h3>
{% for user in owners %}
<ul>
<li>{{ user.name }} - {{ user.email }}</li>
<form method="post" action="/s/{{ request.shop.id }}/remove-user">
<input
name = "user_id"
type = "hidden"
id = "user_id"
value = "{{ user.id }}"
required />
<input type="submit" name="submit" value="remove" />
</form>
</ul>
{% endfor %}
<h3>editors</h3>
{% for user in editors %}
<ul>
<li>{{ user.name }} - {{ user.email }}</li>
<form method="post" action="/s/{{ request.shop.id }}/remove-user">
<input
name = "user_id"
type = "hidden"
id = "user_id"
value = "{{ user.id }}"
required />
<input type="submit" name="submit" value="remove" />
</form>
</ul>
{% endfor %}
<h3>members</h3>
{% for user in members %}
<ul>
<li>{{ user.name }} - {{ user.email }}</li>
<form method="post" action="/s/{{ request.shop.id }}/remove-user">
<input
name = "user_id"
type = "hidden"
id = "user_id"
value = "{{ user.id }}"
required />
<input type="submit" name="submit" value="remove" />
</form>
</ul>
{% endfor %}
</section>
</section>
{%- endblock -%}

View file

@ -202,7 +202,6 @@ class AuthenticatedFunctionalTests(FunctionalTests):
def log_in_user(self, user_creds):
# log in user.
#res_login = self.testapp.get(
res_login = self.testapp.post(
"/verification-challenge?email={}&raw-otp={}&submit".format(*user_creds)
)
@ -259,7 +258,7 @@ class AuthenticatedFunctionalTests(FunctionalTests):
self.log_in_user(self.user2_creds)
redirect_res = self.testapp.get("/p/new", status=302)
res = redirect_res.follow()
self.assertIn(b"First we must create a shop.", res.body)
self.assertIn(b"You must have a shop editor role to access that.", res.body)
def test_create_new_shop(self, user_creds=None, shop_params=None, log_out_user=False):
@ -373,7 +372,7 @@ class AuthenticatedFunctionalTests(FunctionalTests):
if log_out_user:
self.testapp.get("/log-out")
def test_new_product_when_user_does_not_own_the_shop(self):
def test_new_product_when_user_does_not_have_editor_role_on_shop(self):
# log in user1.
self.log_in_user(self.user1_creds)
@ -395,7 +394,7 @@ class AuthenticatedFunctionalTests(FunctionalTests):
params = self.product1_params
redirect_res = self.testapp.post("/p/new?shop_id={}".format(shop_id), params)
res = redirect_res.follow()
self.assertIn("You do not own that Shop.", res.body.decode())
self.assertIn("You must have a shop editor role to access that.", res.body.decode())
def test_new_product_price_history(self):
self.test_new_product(

View file

@ -66,19 +66,52 @@ def shop_is_ready_required(
# view decorator.
def super_fly_required(fn):
"""This view requires that the request has a super fly admin."""
# TODO: don't rely on this hack for super fly admin.
def wrapped(request):
if (
request.user
and request.user.authenticated
and request.user.name == "Admin"
):
return fn(request)
request.session.flash(
("You must be a super fly admin to access that.", "error")
)
return HTTPFound(get_referer_or_home(request))
def shop_owner_required(
flash_msg="You must have a shop owner role to access that.",
flash_level="error",
):
"""This view requires that the user of the request has a shop owner role.
for protecting shop settings & shop user pages."""
def wrapped(fn):
def inner(request):
if (
request.user is not None
and request.user.authenticated
and request.shop is not None
and request.user in request.shop.owners
):
return fn(request)
request.session.flash(
(flash_msg, flash_level)
)
return HTTPFound(get_referer_or_home(request))
return inner
return wrapped
# view decorator.
def shop_editor_required(
flash_msg="You must have a shop editor role to access that.",
flash_level="error",
):
"""This view requires that the user of the request has a shop editor role.
for protecting product & content edit pages."""
def wrapped(fn):
def inner(request):
if (
request.user is not None
and request.user.authenticated
and request.shop is not None
and request.user in request.shop.editors
):
return fn(request)
request.session.flash(
(flash_msg, flash_level)
)
return HTTPFound(get_referer_or_home(request))
return inner
return wrapped

View file

@ -2,6 +2,7 @@ from pyramid.view import view_config
from . import (
user_required,
shop_owner_required,
get_referer_or_home,
)
@ -52,17 +53,13 @@ def coupon(request):
@view_config(route_name="coupon_new1", renderer="coupon_new.j2")
@view_config(route_name="coupon_new2", renderer="coupon_new.j2")
@user_required()
@shop_owner_required()
def coupon_new(request):
shop = request.user.active_shop
if request.shop:
shop = request.shop
if request.user not in shop.users:
request.session.flash(("You do not own that Shop.", "error"))
return HTTPFound(get_referer_or_home(request))
code = request.params.get("code")
description = request.params.get("description")
action_type = request.params.get("action_type")

View file

@ -2,6 +2,7 @@ from pyramid.view import view_config
from . import (
user_required,
shop_editor_required,
get_referer_or_home,
)
@ -85,21 +86,13 @@ def product(request):
@view_config(route_name="product_new", renderer="product_new.j2")
@view_config(route_name="content_new", renderer="content_new.j2")
@user_required()
@shop_editor_required()
def product_new(request):
if request.shop is None and request.user.active_shop is None:
msg = ("First we must create a shop.", "info")
request.session.flash(msg)
return HTTPFound("/s/new")
shop = request.user.active_shop
if request.shop:
shop = request.shop
if request.user not in shop.users:
request.session.flash(("You do not own that Shop.", "error"))
return HTTPFound(get_referer_or_home(request))
title = request.params.get("title", "")
description = request.params.get("description", "")
price = request.params.get("price", "")
@ -144,14 +137,10 @@ def product_new(request):
@view_config(route_name="product_description_edit", renderer="markup_editor.j2")
@user_required()
@shop_editor_required()
def product_edit_description(request):
product = request.product
if request.user.can_not_edit_product(product):
request.session.flash(("You do not own that Product.", "error"))
return HTTPFound(get_referer_or_home(request))
raw_markup_data = request.params.get(
"markup-editor-textarea",
# TODO: rename this column to description_raw.
@ -177,16 +166,12 @@ def product_edit_description(request):
@view_config(route_name="product_edit", renderer="product_edit.j2")
@view_config(route_name="product_edit2", renderer="product_edit.j2")
@user_required()
@shop_editor_required()
def product_edit(request):
product_modified = False
product = request.product
if request.user.can_not_edit_product(product):
request.session.flash(("You do not own that Product.", "error"))
return HTTPFound(get_referer_or_home(request))
title = request.params.get("title", product.title)
description = request.params.get("description", product.description)
price = request.params.get("price", product.price)
@ -325,7 +310,7 @@ def product_edit(request):
@view_config(route_name="product_bundle_edit")
@view_config(route_name="product_bundle_edit2")
@user_required()
@shop_editor_required()
def product_bundle_edit(request):
from ..models.product import get_products_by_ids
@ -337,10 +322,6 @@ def product_bundle_edit(request):
if product.is_bundle == False:
request.session.flash(("You may only add products to a Product bundle.", "error"))
if request.user.can_not_edit_product(product):
request.session.flash(("You do not own that Product bundle.", "error"))
return HTTPFound(get_referer_or_home(request))
bundle_data = request.params.get("bundle-data", None)
if bundle_data:
@ -358,7 +339,7 @@ def product_bundle_edit(request):
@view_config(route_name="product_remove_bundled_product")
@user_required()
@shop_editor_required()
def product_bundle_remove_product(request):
from ..models.product import get_product_by_id
@ -371,10 +352,6 @@ def product_bundle_remove_product(request):
if product.is_bundle == False:
request.session.flash(("You may only remove products from a Product bundle.", "error"))
if request.user.can_not_edit_product(product):
request.session.flash(("You do not own that Product bundle.", "error"))
return HTTPFound(get_referer_or_home(request))
if product.remove_product_from_bundle(bundled_product) == False:
request.session.flash((product.error_message, "error"))
else:

View file

@ -2,7 +2,11 @@ import mimetypes
from pyramid.view import view_config
from . import user_required
from . import (
user_required,
shop_owner_required,
shop_editor_required,
)
from ..models.shop import (
Shop,
@ -17,10 +21,17 @@ from ..models.product import (
get_products_by_keywords,
)
from ..models.user import (
get_user_by_id,
get_or_create_user_by_email,
)
from ..models.shop_search_request import (
ShopSearchRequest,
)
from ..lib.mail import send_invite_email
from pyramid.httpexceptions import HTTPFound
from miscutils.phone_numbers import is_phone_number_valid
@ -72,15 +83,13 @@ def shop(request):
@view_config(route_name="shop_products", renderer="shop_products.j2")
@user_required()
@shop_editor_required()
def shop_products(request):
msg = None
shop = get_shop_from_matchdict(request)
if shop is None:
msg = ("That shop_id does not exist.", "error")
elif request.user.can_not_edit_shop(shop):
msg = ("That shop is not owned by you.", "error")
if msg:
request.session.flash(msg)
@ -157,10 +166,7 @@ def shop_new(request):
else:
shop = Shop(name, phone_number, billing_address, description)
if len(request.user.shops) == 0:
shop.add_user_to_shop(request.user, default=True)
else:
shop.add_user_to_shop(request.user)
shop.add_user_to_shop(request.user)
request.user.set_active_shop(shop)
request.dbsession.add(shop)
request.dbsession.add(request.user)
@ -182,7 +188,7 @@ def shop_new(request):
@view_config(route_name="user_shop_activate")
@user_required()
@shop_editor_required()
def shop_activate(request):
shop = get_shop_from_matchdict(
request, prefetched_shop=request.user.active_shop
@ -191,9 +197,6 @@ def shop_activate(request):
if shop is None:
msg = ("That shop_id does not exist.", "error")
elif request.user.can_not_edit_shop(shop):
msg = ("That shop is not owned by you.", "error")
elif shop == request.user.active_shop:
msg = ("This shop is already your active shop.", "success")
@ -226,16 +229,12 @@ def shop_privacy_policy(request):
@view_config(route_name="shop_edit_terms_of_service", renderer="markup_editor.j2")
@user_required()
@shop_owner_required()
def shop_edit_terms_of_service(request):
shop = request.user.active_shop
if request.shop:
shop = request.shop
if request.user.can_not_edit_shop(shop):
request.session.flash(("You do not own that Shop.", "error"))
return HTTPFound(get_referer_or_home(request))
raw_markup_data = request.params.get(
"markup-editor-textarea",
shop.terms_of_service_raw
@ -257,16 +256,12 @@ def shop_edit_terms_of_service(request):
@view_config(route_name="shop_edit_privacy_policy", renderer="markup_editor.j2")
@user_required()
@shop_owner_required()
def shop_edit_privacy_policy(request):
shop = request.user.active_shop
if request.shop:
shop = request.shop
if request.user.can_not_edit_shop(shop):
request.session.flash(("You do not own that Shop.", "error"))
return HTTPFound(get_referer_or_home(request))
raw_markup_data = request.params.get(
"markup-editor-textarea",
shop.privacy_policy_raw
@ -287,17 +282,60 @@ def shop_edit_privacy_policy(request):
}
@view_config(route_name="shop_users", renderer="shop_users.j2")
@shop_owner_required()
def shop_users(request):
email_to_invite = request.params.get("email-to-invite")
role_id = request.params.get("role-id")
if email_to_invite and role_id:
# get or create new user if it doesn't exist.
user = get_or_create_user_by_email(request.dbsession, email_to_invite)
# make new user a shop user of the proper role.
request.shop.add_user_to_shop(user, role_id)
# commit changes to database.
request.dbsession.add(user)
# send an invitation email to the new user.
send_invite_email(request, email_to_invite, request.user, request.shop)
msg = ("You invited {} to the shop.".format(email_to_invite), "success")
request.session.flash(msg)
return {
"owners": request.shop.owners,
"editors": request.shop.editors,
"members": request.shop.members,
}
@view_config(route_name="shop_user_remove")
@shop_owner_required()
def shop_user_remove(request):
user_id = request.params.get("user_id", None)
if user_id:
user = get_user_by_id(request.dbsession, user_id)
if user:
if user == request.user:
msg = ("Sheepishly refusing to remove myself!".format(user.email), "error")
request.session.flash(msg)
else:
user_shop = request.shop.get_usershop_for_user(user)
request.dbsession.delete(user_shop)
msg = ("You removed {} from the shop.".format(user.email), "success")
request.session.flash(msg)
return HTTPFound("/s/{}/users".format(request.shop.id))
@view_config(route_name="shop_settings", renderer="shop_settings.j2")
@user_required()
@shop_owner_required()
def shop_settings(request):
shop = request.user.active_shop
if request.shop:
shop = request.shop
if request.user.can_not_edit_shop(shop):
request.session.flash(("You do not own that Shop.", "error"))
return HTTPFound(get_referer_or_home(request))
shop = request.shop
name = request.params.get("name", shop.name)
phone_number = request.params.get("phone_number", shop.phone_number)
@ -317,7 +355,7 @@ def shop_settings(request):
stripe_public_api_key = request.params.get("stripe_public_api_key", shop.stripe_public_api_key)
stripe_secret_api_key = request.params.get("stripe_secret_api_key", shop.stripe_secret_api_key)
maint_mode_checkbox = request.params.get("maint-mode-checkbox", bool_to_checkbox(shop.maint_mode))
maint_mode_checkbox = request.params.get("maint-mode-checkbox", "off")
maint_mode = checkbox_to_bool(maint_mode_checkbox)
s3_webhook_key = request.params.get("key")
@ -408,7 +446,6 @@ def shop_settings(request):
msg = ("The shop's stripe_secret_api_key must start with 'sk_'.", "error")
request.session.flash(msg)
print("{} {}".format(shop.maint_mode, maint_mode))
if shop.maint_mode != maint_mode:
shop.maint_mode = maint_mode
request.session.flash(
@ -508,7 +545,6 @@ def shop_settings(request):
continue
signed_posts[file_key]["fields"].update(condition)
return {
"name": name,
"description": description,
@ -523,7 +559,6 @@ def shop_settings(request):
"plausible_domain_name": plausible_domain_name,
"stripe_public_api_key": stripe_public_api_key,
"stripe_secret_api_key": stripe_secret_api_key,
"maint_mode": maint_mode,
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}