mirror of
https://github.com/GSA/notifications-api.git
synced 2026-08-13 18:37:58 -04:00
Merge pull request #1695 from alphagov/org-user-endpoints
Organisation user endpoints
This commit is contained in:
@@ -108,6 +108,8 @@ def register_blueprint(application):
|
||||
from app.letters.rest import letter_job
|
||||
from app.billing.rest import billing_blueprint
|
||||
from app.organisation.rest import organisation_blueprint
|
||||
from app.organisation.invite_rest import organisation_invite_blueprint
|
||||
from app.organisation.accept_organisation_invite import accept_organisation_invite_blueprint
|
||||
|
||||
service_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(service_blueprint, url_prefix='/service')
|
||||
@@ -181,6 +183,12 @@ def register_blueprint(application):
|
||||
organisation_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(organisation_blueprint, url_prefix='/organisations')
|
||||
|
||||
organisation_invite_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(organisation_invite_blueprint)
|
||||
|
||||
accept_organisation_invite_blueprint.before_request(requires_admin_auth)
|
||||
application.register_blueprint(accept_organisation_invite_blueprint)
|
||||
|
||||
|
||||
def register_v2_blueprints(application):
|
||||
from app.v2.inbound_sms.get_inbound_sms import v2_inbound_sms_blueprint as get_inbound_sms
|
||||
|
||||
@@ -9,6 +9,7 @@ from itsdangerous import SignatureExpired
|
||||
from notifications_utils.url_safe_token import check_token
|
||||
|
||||
from app.dao.invited_user_dao import get_invited_user_by_id
|
||||
from app.dao.organisation_dao import dao_get_invited_organisation_user
|
||||
|
||||
from app.errors import (
|
||||
register_errors,
|
||||
@@ -24,7 +25,10 @@ register_errors(accept_invite)
|
||||
|
||||
@accept_invite.route('/<token>', methods=['GET'])
|
||||
def get_invited_user_by_token(token):
|
||||
|
||||
"""
|
||||
This method is now deprecated,
|
||||
in favor of a single accept_invite endpoint for both service and organisation invitations
|
||||
"""
|
||||
max_age_seconds = 60 * 60 * 24 * current_app.config['INVITATION_EXPIRATION_DAYS']
|
||||
|
||||
try:
|
||||
@@ -41,3 +45,29 @@ def get_invited_user_by_token(token):
|
||||
invited_user = get_invited_user_by_id(invited_user_id)
|
||||
|
||||
return jsonify(data=invited_user_schema.dump(invited_user).data), 200
|
||||
|
||||
|
||||
@accept_invite.route('/<invitation_type>/<token>', methods=['GET'])
|
||||
def validate_invitation_token(invitation_type, token):
|
||||
|
||||
max_age_seconds = 60 * 60 * 24 * current_app.config['INVITATION_EXPIRATION_DAYS']
|
||||
|
||||
try:
|
||||
invited_user_id = check_token(token,
|
||||
current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT'],
|
||||
max_age_seconds)
|
||||
except SignatureExpired:
|
||||
errors = {'invitation':
|
||||
['Your invitation to GOV.UK Notify has expired. '
|
||||
'Please ask the person that invited you to send you another one']}
|
||||
raise InvalidRequest(errors, status_code=400)
|
||||
|
||||
if invitation_type == 'service':
|
||||
invited_user = get_invited_user_by_id(invited_user_id)
|
||||
return jsonify(data=invited_user_schema.dump(invited_user).data), 200
|
||||
elif invitation_type == 'organisation':
|
||||
invited_user = dao_get_invited_organisation_user(invited_user_id)
|
||||
return jsonify(data=invited_user.serialize()), 200
|
||||
else:
|
||||
raise InvalidRequest("Unrecognised invitation type: {}".format(invitation_type))
|
||||
|
||||
@@ -22,6 +22,7 @@ from app import performance_platform_client, deskpro_client
|
||||
from app.dao.date_util import get_month_start_and_end_date_in_utc
|
||||
from app.dao.inbound_sms_dao import delete_inbound_sms_created_more_than_a_week_ago
|
||||
from app.dao.invited_user_dao import delete_invitations_created_more_than_two_days_ago
|
||||
from app.dao.invited_org_user_dao import delete_org_invitations_created_more_than_two_days_ago
|
||||
from app.dao.jobs_dao import (
|
||||
dao_get_letter_job_ids_by_status,
|
||||
dao_set_scheduled_jobs_to_pending,
|
||||
@@ -184,9 +185,10 @@ def delete_letter_notifications_older_than_seven_days():
|
||||
def delete_invitations():
|
||||
try:
|
||||
start = datetime.utcnow()
|
||||
deleted = delete_invitations_created_more_than_two_days_ago()
|
||||
deleted_invites = delete_invitations_created_more_than_two_days_ago()
|
||||
deleted_invites += delete_org_invitations_created_more_than_two_days_ago()
|
||||
current_app.logger.info(
|
||||
"Delete job started {} finished {} deleted {} invitations".format(start, datetime.utcnow(), deleted)
|
||||
"Delete job started {} finished {} deleted {} invitations".format(start, datetime.utcnow(), deleted_invites)
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
current_app.logger.exception("Failed to delete invitations")
|
||||
|
||||
@@ -149,6 +149,7 @@ class Config(object):
|
||||
ALREADY_REGISTERED_EMAIL_TEMPLATE_ID = '0880fbb1-a0c6-46f0-9a8e-36c986381ceb'
|
||||
CHANGE_EMAIL_CONFIRMATION_TEMPLATE_ID = 'eb4d9930-87ab-4aef-9bce-786762687884'
|
||||
SERVICE_NOW_LIVE_TEMPLATE_ID = '618185c6-3636-49cd-b7d2-6f6f5eb3bdde'
|
||||
ORGANISATION_INVITATION_EMAIL_TEMPLATE_ID = '203566f0-d835-47c5-aa06-932439c86573'
|
||||
|
||||
BROKER_URL = 'sqs://'
|
||||
BROKER_TRANSPORT_OPTIONS = {
|
||||
|
||||
29
app/dao/invited_org_user_dao.py
Normal file
29
app/dao/invited_org_user_dao.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime, timedelta
|
||||
from app import db
|
||||
|
||||
from app.models import InvitedOrganisationUser
|
||||
|
||||
|
||||
def save_invited_org_user(invited_org_user):
|
||||
db.session.add(invited_org_user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_invited_org_user(organisation_id, invited_org_user_id):
|
||||
return InvitedOrganisationUser.query.filter_by(organisation_id=organisation_id, id=invited_org_user_id).one()
|
||||
|
||||
|
||||
def get_invited_org_user_by_id(invited_org_user_id):
|
||||
return InvitedOrganisationUser.query.filter_by(id=invited_org_user_id).one()
|
||||
|
||||
|
||||
def get_invited_org_users_for_organisation(organisation_id):
|
||||
return InvitedOrganisationUser.query.filter_by(organisation_id=organisation_id).all()
|
||||
|
||||
|
||||
def delete_org_invitations_created_more_than_two_days_ago():
|
||||
deleted = db.session.query(InvitedOrganisationUser).filter(
|
||||
InvitedOrganisationUser.created_at <= datetime.utcnow() - timedelta(days=2)
|
||||
).delete()
|
||||
db.session.commit()
|
||||
return deleted
|
||||
@@ -1,6 +1,10 @@
|
||||
from app import db
|
||||
from app.dao.dao_utils import transactional
|
||||
from app.models import Organisation
|
||||
from app.models import (
|
||||
Organisation,
|
||||
InvitedOrganisationUser,
|
||||
User
|
||||
)
|
||||
|
||||
|
||||
def dao_get_organisations():
|
||||
@@ -42,3 +46,23 @@ def dao_add_service_to_organisation(service, organisation_id):
|
||||
).one()
|
||||
|
||||
organisation.services.append(service)
|
||||
|
||||
|
||||
def dao_get_invited_organisation_user(user_id):
|
||||
return InvitedOrganisationUser.query.filter_by(id=user_id).one()
|
||||
|
||||
|
||||
def dao_get_users_for_organisation(organisation_id):
|
||||
return User.query.filter(
|
||||
User.organisations.any(id=organisation_id),
|
||||
User.state == 'active'
|
||||
).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@transactional
|
||||
def dao_add_user_to_organisation(organisation_id, user_id):
|
||||
organisation = dao_get_organisation_by_id(organisation_id)
|
||||
user = User.query.filter_by(id=user_id).one()
|
||||
user.organisations.append(organisation)
|
||||
db.session.add(organisation)
|
||||
return user
|
||||
|
||||
@@ -60,13 +60,6 @@ def get_invited_users_by_service(service_id):
|
||||
return jsonify(data=invited_user_schema.dump(invited_users, many=True).data), 200
|
||||
|
||||
|
||||
@invite.route('/<invited_user_id>', methods=['GET'])
|
||||
def get_invited_user_by_service_and_id(service_id, invited_user_id):
|
||||
invited_user = get_invited_user(service_id=service_id, invited_user_id=invited_user_id)
|
||||
|
||||
return jsonify(data=invited_user_schema.dump(invited_user).data), 200
|
||||
|
||||
|
||||
@invite.route('/<invited_user_id>', methods=['POST'])
|
||||
def update_invited_user(service_id, invited_user_id):
|
||||
fetched = get_invited_user(service_id=service_id, invited_user_id=invited_user_id)
|
||||
|
||||
@@ -116,6 +116,10 @@ class User(db.Model):
|
||||
'Service',
|
||||
secondary='user_to_service',
|
||||
backref=db.backref('user_to_service', lazy='dynamic'))
|
||||
organisations = db.relationship(
|
||||
'Organisation',
|
||||
secondary='user_to_organisation',
|
||||
backref=db.backref('user_to_organisation', lazy='dynamic'))
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
@@ -225,13 +229,7 @@ organisation_to_service = db.Table(
|
||||
'organisation_to_service',
|
||||
db.Model.metadata,
|
||||
# service_id is a primary key as you can only have one organisation per service
|
||||
db.Column(
|
||||
'service_id',
|
||||
UUID(as_uuid=True),
|
||||
db.ForeignKey('services.id'),
|
||||
primary_key=True,
|
||||
unique=True,
|
||||
nullable=False),
|
||||
db.Column('service_id', UUID(as_uuid=True), db.ForeignKey('services.id'), primary_key=True, nullable=False),
|
||||
db.Column('organisation_id', UUID(as_uuid=True), db.ForeignKey('organisation.id'), nullable=False),
|
||||
)
|
||||
|
||||
@@ -249,11 +247,6 @@ class Organisation(db.Model):
|
||||
secondary='organisation_to_service',
|
||||
uselist=True)
|
||||
|
||||
users = db.relationship(
|
||||
'User',
|
||||
secondary='user_to_organisation',
|
||||
backref=db.backref('organisations', lazy='dynamic'))
|
||||
|
||||
def serialize(self):
|
||||
serialized = {
|
||||
"id": str(self.id),
|
||||
@@ -1452,6 +1445,16 @@ class InvitedOrganisationUser(db.Model):
|
||||
default=INVITE_PENDING
|
||||
)
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'email_address': self.email_address,
|
||||
'invited_by': str(self.invited_by_id),
|
||||
'organisation': str(self.organisation_id),
|
||||
'created_at': self.created_at.strftime(DATETIME_FORMAT),
|
||||
'status': self.status
|
||||
}
|
||||
|
||||
|
||||
# Service Permissions
|
||||
MANAGE_USERS = 'manage_users'
|
||||
|
||||
30
app/organisation/accept_organisation_invite.py
Normal file
30
app/organisation/accept_organisation_invite.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from flask import Blueprint, jsonify, current_app
|
||||
from itsdangerous import SignatureExpired
|
||||
from notifications_utils.url_safe_token import check_token
|
||||
|
||||
from app.dao.organisation_dao import dao_get_invited_organisation_user
|
||||
from app.errors import register_errors, InvalidRequest
|
||||
|
||||
accept_organisation_invite_blueprint = Blueprint(
|
||||
'accept_organisation_invite', __name__,
|
||||
url_prefix='/organisation-invitation')
|
||||
|
||||
register_errors(accept_organisation_invite_blueprint)
|
||||
|
||||
|
||||
@accept_organisation_invite_blueprint.route("/<token>", methods=['GET'])
|
||||
def accept_organisation_invitation(token):
|
||||
max_age_seconds = 60 * 60 * 24 * current_app.config['INVITATION_EXPIRATION_DAYS']
|
||||
|
||||
try:
|
||||
invited_user_id = check_token(token,
|
||||
current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT'],
|
||||
max_age_seconds)
|
||||
except SignatureExpired:
|
||||
errors = {'invitation': ['Your invitation to GOV.UK Notify has expired. '
|
||||
'Please ask the person that invited you to send you another one']}
|
||||
raise InvalidRequest(errors, status_code=400)
|
||||
invited_user = dao_get_invited_organisation_user(invited_user_id)
|
||||
|
||||
return jsonify(data=invited_user.serialize()), 200
|
||||
98
app/organisation/invite_rest.py
Normal file
98
app/organisation/invite_rest.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from flask import (
|
||||
Blueprint,
|
||||
request,
|
||||
jsonify,
|
||||
current_app)
|
||||
from notifications_utils.url_safe_token import generate_token
|
||||
|
||||
from app.config import QueueNames
|
||||
from app.dao.invited_org_user_dao import (
|
||||
save_invited_org_user,
|
||||
get_invited_org_user,
|
||||
get_invited_org_users_for_organisation
|
||||
)
|
||||
from app.dao.templates_dao import dao_get_template_by_id
|
||||
from app.errors import register_errors
|
||||
from app.models import EMAIL_TYPE, KEY_TYPE_NORMAL, InvitedOrganisationUser
|
||||
from app.notifications.process_notifications import persist_notification, send_notification_to_queue
|
||||
from app.schema_validation import validate
|
||||
from app.organisation.organisation_schema import (
|
||||
post_create_invited_org_user_status_schema,
|
||||
post_update_invited_org_user_status_schema
|
||||
)
|
||||
|
||||
organisation_invite_blueprint = Blueprint(
|
||||
'organisation_invite', __name__,
|
||||
url_prefix='/organisation/<uuid:organisation_id>/invite')
|
||||
|
||||
register_errors(organisation_invite_blueprint)
|
||||
|
||||
|
||||
@organisation_invite_blueprint.route('', methods=['POST'])
|
||||
def invite_user_to_org(organisation_id):
|
||||
data = request.get_json()
|
||||
validate(data, post_create_invited_org_user_status_schema)
|
||||
|
||||
invited_org_user = InvitedOrganisationUser(
|
||||
email_address=data['email_address'],
|
||||
invited_by_id=data['invited_by'],
|
||||
organisation_id=organisation_id
|
||||
)
|
||||
save_invited_org_user(invited_org_user)
|
||||
|
||||
template = dao_get_template_by_id(current_app.config['ORGANISATION_INVITATION_EMAIL_TEMPLATE_ID'])
|
||||
|
||||
saved_notification = persist_notification(
|
||||
template_id=template.id,
|
||||
template_version=template.version,
|
||||
recipient=invited_org_user.email_address,
|
||||
service=template.service,
|
||||
personalisation={
|
||||
'user_name': invited_org_user.invited_by.name,
|
||||
'organisation_name': invited_org_user.organisation.name,
|
||||
'url': invited_org_user_url(
|
||||
invited_org_user.id,
|
||||
data.get('invite_link_host'),
|
||||
),
|
||||
},
|
||||
notification_type=EMAIL_TYPE,
|
||||
api_key_id=None,
|
||||
key_type=KEY_TYPE_NORMAL,
|
||||
reply_to_text=invited_org_user.invited_by.email_address
|
||||
)
|
||||
|
||||
send_notification_to_queue(saved_notification, research_mode=False, queue=QueueNames.NOTIFY)
|
||||
|
||||
return jsonify(data=invited_org_user.serialize()), 201
|
||||
|
||||
|
||||
@organisation_invite_blueprint.route('', methods=['GET'])
|
||||
def get_invited_org_users_by_organisation(organisation_id):
|
||||
invited_org_users = get_invited_org_users_for_organisation(organisation_id)
|
||||
return jsonify(data=[x.serialize() for x in invited_org_users]), 200
|
||||
|
||||
|
||||
@organisation_invite_blueprint.route('/<invited_org_user_id>', methods=['POST'])
|
||||
def update_org_invite_status(organisation_id, invited_org_user_id):
|
||||
fetched = get_invited_org_user(organisation_id=organisation_id, invited_org_user_id=invited_org_user_id)
|
||||
|
||||
data = request.get_json()
|
||||
validate(data, post_update_invited_org_user_status_schema)
|
||||
|
||||
fetched.status = data['status']
|
||||
save_invited_org_user(fetched)
|
||||
|
||||
return jsonify(data=fetched.serialize()), 200
|
||||
|
||||
|
||||
def invited_org_user_url(invited_org_user_id, invite_link_host=None):
|
||||
token = generate_token(
|
||||
str(invited_org_user_id),
|
||||
current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT']
|
||||
)
|
||||
|
||||
if invite_link_host is None:
|
||||
invite_link_host = current_app.config['ADMIN_BASE_URL']
|
||||
|
||||
return '{0}/organisation-invitation/{1}'.format(invite_link_host, token)
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.models import INVITED_USER_STATUS_TYPES
|
||||
from app.schema_validation.definitions import uuid
|
||||
|
||||
post_create_organisation_schema = {
|
||||
@@ -31,3 +32,27 @@ post_link_service_to_organisation_schema = {
|
||||
},
|
||||
"required": ["service_id"]
|
||||
}
|
||||
|
||||
|
||||
post_create_invited_org_user_status_schema = {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"description": "POST create organisation invite schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_address": {"type": "string", "format": "email_address"},
|
||||
"invited_by": uuid,
|
||||
"invite_link_host": {"type": "string"}
|
||||
},
|
||||
"required": ["email_address", "invited_by"]
|
||||
}
|
||||
|
||||
|
||||
post_update_invited_org_user_status_schema = {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"description": "POST update organisation invite schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"enum": INVITED_USER_STATUS_TYPES}
|
||||
},
|
||||
"required": ["status"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ from app.dao.organisation_dao import (
|
||||
dao_get_organisation_services,
|
||||
dao_update_organisation,
|
||||
dao_add_service_to_organisation,
|
||||
dao_get_users_for_organisation,
|
||||
dao_add_user_to_organisation
|
||||
)
|
||||
from app.dao.services_dao import dao_fetch_service_by_id
|
||||
from app.errors import register_errors, InvalidRequest
|
||||
@@ -18,6 +20,7 @@ from app.organisation.organisation_schema import (
|
||||
post_link_service_to_organisation_schema,
|
||||
)
|
||||
from app.schema_validation import validate
|
||||
from app.schemas import user_schema
|
||||
|
||||
organisation_blueprint = Blueprint('organisation', __name__)
|
||||
register_errors(organisation_blueprint)
|
||||
@@ -90,3 +93,17 @@ def get_organisation_services(organisation_id):
|
||||
services = dao_get_organisation_services(organisation_id)
|
||||
sorted_services = sorted(services, key=lambda s: (-s.active, s.name))
|
||||
return jsonify([s.serialize_for_org_dashboard() for s in sorted_services])
|
||||
|
||||
|
||||
@organisation_blueprint.route('/<uuid:organisation_id>/users/<uuid:user_id>', methods=['POST'])
|
||||
def add_user_to_organisation(organisation_id, user_id):
|
||||
new_org_user = dao_add_user_to_organisation(organisation_id, user_id)
|
||||
return jsonify(data=user_schema.dump(new_org_user).data), 200
|
||||
|
||||
|
||||
@organisation_blueprint.route('/<uuid:organisation_id>/users', methods=['GET'])
|
||||
def get_organisation_users(organisation_id):
|
||||
org_users = dao_get_users_for_organisation(organisation_id)
|
||||
|
||||
result = user_schema.dump(org_users, many=True)
|
||||
return jsonify(data=result.data)
|
||||
|
||||
@@ -100,8 +100,13 @@ class UserSchema(BaseSchema):
|
||||
class Meta:
|
||||
model = models.User
|
||||
exclude = (
|
||||
"updated_at", "created_at", "user_to_service",
|
||||
"_password", "verify_codes")
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"user_to_service",
|
||||
"user_to_organisation",
|
||||
"_password",
|
||||
"verify_codes"
|
||||
)
|
||||
strict = True
|
||||
|
||||
@validates('name')
|
||||
|
||||
80
migrations/versions/0168_add_org_invite_template.py
Normal file
80
migrations/versions/0168_add_org_invite_template.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
|
||||
Revision ID: 0168_add_org_invite_template
|
||||
Revises: 0167_add_precomp_letter_svc_perm
|
||||
Create Date: 2018-02-16 14:16:43.618062
|
||||
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from alembic import op
|
||||
from flask import current_app
|
||||
|
||||
|
||||
revision = '0168_add_org_invite_template'
|
||||
down_revision = '0167_add_precomp_letter_svc_perm'
|
||||
|
||||
|
||||
template_id = '203566f0-d835-47c5-aa06-932439c86573'
|
||||
|
||||
|
||||
def upgrade():
|
||||
template_insert = """
|
||||
INSERT INTO templates (id, name, template_type, created_at, content, archived, service_id, subject, created_by_id, version, process_type)
|
||||
VALUES ('{}', '{}', '{}', '{}', '{}', False, '{}', '{}', '{}', 1, '{}')
|
||||
"""
|
||||
template_history_insert = """
|
||||
INSERT INTO templates_history (id, name, template_type, created_at, content, archived, service_id, subject, created_by_id, version, process_type)
|
||||
VALUES ('{}', '{}', '{}', '{}', '{}', False, '{}', '{}', '{}', 1, '{}')
|
||||
"""
|
||||
|
||||
template_content = '\n'.join([
|
||||
"((user_name)) has invited you to collaborate on ((organisation_name)) on GOV.UK Notify.",
|
||||
"",
|
||||
"GOV.UK Notify makes it easy to keep people updated by helping you send text messages, emails and letters.",
|
||||
"",
|
||||
"Open this link to create an account on GOV.UK Notify:",
|
||||
"((url))",
|
||||
"",
|
||||
"This invitation will stop working at midnight tomorrow. This is to keep ((organisation_name)) secure.",
|
||||
])
|
||||
|
||||
template_name = "Notify organisation invitation email"
|
||||
template_subject = '((user_name)) has invited you to collaborate on ((organisation_name)) on GOV.UK Notify'
|
||||
|
||||
op.execute(
|
||||
template_history_insert.format(
|
||||
template_id,
|
||||
template_name,
|
||||
'email',
|
||||
datetime.utcnow(),
|
||||
template_content,
|
||||
current_app.config['NOTIFY_SERVICE_ID'],
|
||||
template_subject,
|
||||
current_app.config['NOTIFY_USER_ID'],
|
||||
'normal'
|
||||
)
|
||||
)
|
||||
|
||||
op.execute(
|
||||
template_insert.format(
|
||||
template_id,
|
||||
template_name,
|
||||
'email',
|
||||
datetime.utcnow(),
|
||||
template_content,
|
||||
current_app.config['NOTIFY_SERVICE_ID'],
|
||||
template_subject,
|
||||
current_app.config['NOTIFY_USER_ID'],
|
||||
'normal'
|
||||
)
|
||||
)
|
||||
|
||||
# clean up constraints on org_to_service - service_id-org_id constraint is redundant
|
||||
op.drop_constraint('organisation_to_service_service_id_organisation_id_key', 'organisation_to_service', type_='unique')
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DELETE FROM templates_history WHERE id = '{}'".format(template_id))
|
||||
op.execute("DELETE FROM templates WHERE id = '{}'".format(template_id))
|
||||
op.create_unique_constraint('organisation_to_service_service_id_organisation_id_key', 'organisation_to_service', ['service_id', 'organisation_id'])
|
||||
@@ -1,6 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from flask import json
|
||||
import pytest
|
||||
from flask import json, current_app
|
||||
from freezegun import freeze_time
|
||||
from notifications_utils.url_safe_token import generate_token
|
||||
from tests import create_authorization_header
|
||||
@@ -56,3 +57,59 @@ def test_accept_invite_returns_400_when_invited_user_does_not_exist(notify_api):
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == 'No result found'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invitation_type', ['service', 'organisation'])
|
||||
def test_validate_invitation_token_for_expired_token_returns_400(client, invitation_type):
|
||||
with freeze_time('2016-01-01T12:00:00'):
|
||||
token = generate_token(str(uuid.uuid4()), current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT'])
|
||||
url = '/invite/{}/{}'.format(invitation_type, token)
|
||||
auth_header = create_authorization_header()
|
||||
response = client.get(url, headers=[('Content-Type', 'application/json'), auth_header])
|
||||
|
||||
assert response.status_code == 400
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == {'invitation': [
|
||||
'Your invitation to GOV.UK Notify has expired. '
|
||||
'Please ask the person that invited you to send you another one']}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invitation_type', ['service', 'organisation'])
|
||||
def test_validate_invitation_token_returns_200_when_token_valid(
|
||||
client, invitation_type, sample_invited_user, sample_invited_org_user
|
||||
):
|
||||
invited_user = sample_invited_user if invitation_type == 'service' else sample_invited_org_user
|
||||
|
||||
token = generate_token(str(invited_user.id), current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT'])
|
||||
url = '/invite/{}/{}'.format(invitation_type, token)
|
||||
auth_header = create_authorization_header()
|
||||
response = client.get(url, headers=[('Content-Type', 'application/json'), auth_header])
|
||||
|
||||
assert response.status_code == 200
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
if invitation_type == 'service':
|
||||
assert json_resp['data']['id'] == str(sample_invited_user.id)
|
||||
assert json_resp['data']['email_address'] == sample_invited_user.email_address
|
||||
assert json_resp['data']['from_user'] == str(sample_invited_user.user_id)
|
||||
assert json_resp['data']['service'] == str(sample_invited_user.service_id)
|
||||
assert json_resp['data']['status'] == sample_invited_user.status
|
||||
assert json_resp['data']['permissions'] == sample_invited_user.permissions
|
||||
if invitation_type == 'organisation':
|
||||
assert json_resp['data'] == sample_invited_org_user.serialize()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invitation_type', ['service', 'organisation'])
|
||||
def test_validate_invitation_token_returns_400_when_invited_user_does_not_exist(client, invitation_type):
|
||||
token = generate_token(str(uuid.uuid4()), current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT'])
|
||||
url = '/invite/{}/{}'.format(invitation_type, token)
|
||||
auth_header = create_authorization_header()
|
||||
response = client.get(url, headers=[('Content-Type', 'application/json'), auth_header])
|
||||
|
||||
assert response.status_code == 404
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert json_resp['result'] == 'error'
|
||||
assert json_resp['message'] == 'No result found'
|
||||
|
||||
@@ -59,7 +59,8 @@ from tests.app.db import (
|
||||
create_service,
|
||||
create_api_key,
|
||||
create_inbound_number,
|
||||
create_letter_contact
|
||||
create_letter_contact,
|
||||
create_invited_org_user,
|
||||
)
|
||||
|
||||
|
||||
@@ -747,6 +748,16 @@ def sample_invited_user(notify_db,
|
||||
return invited_user
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def sample_invited_org_user(
|
||||
notify_db,
|
||||
notify_db_session,
|
||||
sample_user,
|
||||
sample_organisation
|
||||
):
|
||||
return create_invited_org_user(sample_organisation, sample_user)
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def sample_permission(notify_db,
|
||||
notify_db_session,
|
||||
@@ -920,6 +931,20 @@ def invitation_email_template(notify_db,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def org_invite_email_template(notify_db, notify_db_session):
|
||||
service, user = notify_service(notify_db, notify_db_session)
|
||||
|
||||
return create_custom_template(
|
||||
service=service,
|
||||
user=user,
|
||||
template_config_name='ORGANISATION_INVITATION_EMAIL_TEMPLATE_ID',
|
||||
content='((user_name)) ((organisation_name)) ((url))',
|
||||
subject='Invitation to ((organisation_name))',
|
||||
template_type='email'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def password_reset_email_template(notify_db,
|
||||
notify_db_session):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
from app.dao.organisation_dao import (
|
||||
dao_get_organisations,
|
||||
@@ -8,15 +10,18 @@ from app.dao.organisation_dao import (
|
||||
dao_get_organisation_services,
|
||||
dao_update_organisation,
|
||||
dao_add_service_to_organisation,
|
||||
dao_get_invited_organisation_user,
|
||||
dao_get_users_for_organisation,
|
||||
dao_add_user_to_organisation
|
||||
)
|
||||
from app.models import Organisation
|
||||
|
||||
from tests.app.db import create_organisation, create_service
|
||||
from tests.app.db import create_organisation, create_service, create_user
|
||||
|
||||
|
||||
def test_get_organisations_gets_all_organisations_alphabetically_with_active_organisations_first(
|
||||
notify_db,
|
||||
notify_db_session
|
||||
notify_db,
|
||||
notify_db_session
|
||||
):
|
||||
m_active_org = create_organisation(name='m_active_organisation')
|
||||
z_inactive_org = create_organisation(name='z_inactive_organisation', active=False)
|
||||
@@ -104,3 +109,65 @@ def test_get_organisation_by_service_id(notify_db, notify_db_session, sample_ser
|
||||
|
||||
assert organisation_1 == sample_organisation
|
||||
assert organisation_2 == another_org
|
||||
|
||||
|
||||
def test_dao_get_invited_organisation_user(sample_invited_org_user):
|
||||
invited_org_user = dao_get_invited_organisation_user(sample_invited_org_user.id)
|
||||
assert invited_org_user == sample_invited_org_user
|
||||
|
||||
|
||||
def test_dao_get_invited_organisation_user_returns_none(notify_db):
|
||||
with pytest.raises(expected_exception=SQLAlchemyError):
|
||||
dao_get_invited_organisation_user(uuid.uuid4())
|
||||
|
||||
|
||||
def test_dao_get_users_for_organisation(sample_organisation):
|
||||
first = create_user(email='first@invited.com')
|
||||
second = create_user(email='another@invited.com')
|
||||
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=first.id)
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=second.id)
|
||||
|
||||
results = dao_get_users_for_organisation(organisation_id=sample_organisation.id)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0] == first
|
||||
assert results[1] == second
|
||||
|
||||
|
||||
def test_dao_get_users_for_organisation_returns_empty_list(sample_organisation):
|
||||
results = dao_get_users_for_organisation(organisation_id=sample_organisation.id)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
def test_dao_get_users_for_organisation_only_returns_active_users(sample_organisation):
|
||||
first = create_user(email='first@invited.com')
|
||||
second = create_user(email='another@invited.com')
|
||||
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=first.id)
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=second.id)
|
||||
|
||||
second.state = 'inactive'
|
||||
|
||||
results = dao_get_users_for_organisation(organisation_id=sample_organisation.id)
|
||||
assert len(results) == 1
|
||||
assert results[0] == first
|
||||
|
||||
|
||||
def test_add_user_to_organisation_returns_user(sample_organisation):
|
||||
org_user = create_user()
|
||||
assert not org_user.organisations
|
||||
|
||||
added_user = dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=org_user.id)
|
||||
assert len(added_user.organisations) == 1
|
||||
assert added_user.organisations[0] == sample_organisation
|
||||
|
||||
|
||||
def test_add_user_to_organisation_when_user_does_not_exist(sample_organisation):
|
||||
with pytest.raises(expected_exception=SQLAlchemyError):
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=uuid.uuid4())
|
||||
|
||||
|
||||
def test_add_user_to_organisation_when_organisation_does_not_exist(sample_user):
|
||||
with pytest.raises(expected_exception=SQLAlchemyError):
|
||||
dao_add_user_to_organisation(organisation_id=uuid.uuid4(), user_id=sample_user.id)
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.dao.jobs_dao import dao_create_job
|
||||
from app.dao.service_inbound_api_dao import save_service_inbound_api
|
||||
from app.dao.service_callback_api_dao import save_service_callback_api
|
||||
from app.dao.service_sms_sender_dao import update_existing_sms_sender_with_inbound_number, dao_update_service_sms_sender
|
||||
from app.dao.invited_org_user_dao import save_invited_org_user
|
||||
from app.models import (
|
||||
ApiKey,
|
||||
InboundSms,
|
||||
@@ -30,7 +31,8 @@ from app.models import (
|
||||
SMS_TYPE,
|
||||
KEY_TYPE_NORMAL,
|
||||
AnnualBilling,
|
||||
LetterRate
|
||||
LetterRate,
|
||||
InvitedOrganisationUser,
|
||||
)
|
||||
from app.dao.users_dao import save_model_user
|
||||
from app.dao.notifications_dao import (
|
||||
@@ -492,3 +494,13 @@ def create_organisation(name='test_org_1', active=True):
|
||||
dao_create_organisation(organisation)
|
||||
|
||||
return organisation
|
||||
|
||||
|
||||
def create_invited_org_user(organisation, invited_by, email_address='invite@example.com'):
|
||||
invited_org_user = InvitedOrganisationUser(
|
||||
email_address=email_address,
|
||||
invited_by=invited_by,
|
||||
organisation=organisation,
|
||||
)
|
||||
save_invited_org_user(invited_org_user)
|
||||
return invited_org_user
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import pytest
|
||||
import uuid
|
||||
|
||||
from app.models import Notification, SMS_AUTH_TYPE, EMAIL_AUTH_TYPE
|
||||
from tests import create_authorization_header
|
||||
@@ -161,40 +160,6 @@ def test_get_invited_users_by_service_with_no_invites(client, notify_db, notify_
|
||||
assert len(json_resp['data']) == 0
|
||||
|
||||
|
||||
def test_get_invited_user_by_service_and_id(client, sample_service, sample_invited_user):
|
||||
url = '/service/{}/invite/{}'.format(sample_service.id, sample_invited_user.id)
|
||||
|
||||
auth_header = create_authorization_header()
|
||||
|
||||
response = client.get(
|
||||
url,
|
||||
headers=[('Content-Type', 'application/json'), auth_header]
|
||||
)
|
||||
assert response.status_code == 200
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
|
||||
invite_email_address = sample_invited_user.email_address
|
||||
invite_from = sample_service.users[0]
|
||||
|
||||
assert json_resp['data']['service'] == str(sample_service.id)
|
||||
assert json_resp['data']['email_address'] == invite_email_address
|
||||
assert json_resp['data']['from_user'] == str(invite_from.id)
|
||||
assert json_resp['data']['id']
|
||||
|
||||
|
||||
def test_get_invited_user_by_service_but_unknown_invite_id_returns_404(client, sample_service):
|
||||
unknown_id = uuid.uuid4()
|
||||
url = '/service/{}/invite/{}'.format(sample_service.id, unknown_id)
|
||||
|
||||
auth_header = create_authorization_header()
|
||||
|
||||
response = client.get(
|
||||
url,
|
||||
headers=[('Content-Type', 'application/json'), auth_header]
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_invited_user_set_status_to_cancelled(client, sample_invited_user):
|
||||
data = {'status': 'cancelled'}
|
||||
url = '/service/{0}/invite/{1}'.format(sample_invited_user.service_id, sample_invited_user.id)
|
||||
|
||||
17
tests/app/organisation/test_accept_organisation_invite.py
Normal file
17
tests/app/organisation/test_accept_organisation_invite.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
|
||||
from flask import current_app
|
||||
from notifications_utils.url_safe_token import generate_token
|
||||
|
||||
from tests import create_authorization_header
|
||||
|
||||
|
||||
def test_accept_organisation_invitation(client, sample_invited_org_user):
|
||||
token = generate_token(str(sample_invited_org_user.id), current_app.config['SECRET_KEY'],
|
||||
current_app.config['DANGEROUS_SALT'])
|
||||
url = '/organisation-invitation/{}'.format(token)
|
||||
auth_header = create_authorization_header()
|
||||
response = client.get(url, headers=[('Content-Type', 'application/json'), auth_header])
|
||||
assert response.status_code == 200
|
||||
json_resp = json.loads(response.get_data(as_text=True))
|
||||
assert json_resp['data'] == sample_invited_org_user.serialize()
|
||||
148
tests/app/organisation/test_invite_rest.py
Normal file
148
tests/app/organisation/test_invite_rest.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import pytest
|
||||
|
||||
from app.models import Notification, INVITE_PENDING
|
||||
|
||||
from tests.app.db import create_invited_org_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extra_args, expected_start_of_invite_url', [
|
||||
(
|
||||
{},
|
||||
'http://localhost:6012/organisation-invitation/'
|
||||
),
|
||||
(
|
||||
{'invite_link_host': 'https://www.example.com'},
|
||||
'https://www.example.com/organisation-invitation/'
|
||||
),
|
||||
])
|
||||
def test_create_invited_org_user(
|
||||
admin_request,
|
||||
sample_organisation,
|
||||
sample_user,
|
||||
mocker,
|
||||
org_invite_email_template,
|
||||
extra_args,
|
||||
expected_start_of_invite_url,
|
||||
):
|
||||
mocked = mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
|
||||
email_address = 'invited_user@example.com'
|
||||
|
||||
data = dict(
|
||||
organisation=str(sample_organisation.id),
|
||||
email_address=email_address,
|
||||
invited_by=str(sample_user.id),
|
||||
**extra_args
|
||||
)
|
||||
|
||||
json_resp = admin_request.post(
|
||||
'organisation_invite.invite_user_to_org',
|
||||
organisation_id=sample_organisation.id,
|
||||
_data=data,
|
||||
_expected_status=201
|
||||
)
|
||||
|
||||
assert json_resp['data']['organisation'] == str(sample_organisation.id)
|
||||
assert json_resp['data']['email_address'] == email_address
|
||||
assert json_resp['data']['invited_by'] == str(sample_user.id)
|
||||
assert json_resp['data']['status'] == INVITE_PENDING
|
||||
assert json_resp['data']['id']
|
||||
|
||||
notification = Notification.query.first()
|
||||
|
||||
assert notification.reply_to_text == sample_user.email_address
|
||||
|
||||
assert len(notification.personalisation.keys()) == 3
|
||||
assert notification.personalisation['organisation_name'] == 'sample organisation'
|
||||
assert notification.personalisation['user_name'] == 'Test User'
|
||||
assert notification.personalisation['url'].startswith(expected_start_of_invite_url)
|
||||
assert len(notification.personalisation['url']) > len(expected_start_of_invite_url)
|
||||
|
||||
mocked.assert_called_once_with([(str(notification.id))], queue="notify-internal-tasks")
|
||||
|
||||
|
||||
def test_create_invited_user_invalid_email(admin_request, sample_organisation, sample_user, mocker):
|
||||
mocked = mocker.patch('app.celery.provider_tasks.deliver_email.apply_async')
|
||||
email_address = 'notanemail'
|
||||
|
||||
data = {
|
||||
'service': str(sample_organisation.id),
|
||||
'email_address': email_address,
|
||||
'invited_by': str(sample_user.id),
|
||||
}
|
||||
|
||||
json_resp = admin_request.post(
|
||||
'organisation_invite.invite_user_to_org',
|
||||
organisation_id=sample_organisation.id,
|
||||
_data=data,
|
||||
_expected_status=400
|
||||
)
|
||||
|
||||
assert json_resp['errors'][0]['message'] == 'email_address Not a valid email address'
|
||||
assert mocked.call_count == 0
|
||||
|
||||
|
||||
def test_get_all_invited_users_by_service(admin_request, sample_organisation, sample_user):
|
||||
for i in range(5):
|
||||
create_invited_org_user(
|
||||
sample_organisation,
|
||||
sample_user,
|
||||
email_address='invited_user_{}@service.gov.uk'.format(i)
|
||||
)
|
||||
|
||||
json_resp = admin_request.get(
|
||||
'organisation_invite.get_invited_org_users_by_organisation',
|
||||
organisation_id=sample_organisation.id
|
||||
)
|
||||
|
||||
assert len(json_resp['data']) == 5
|
||||
for invite in json_resp['data']:
|
||||
assert invite['organisation'] == str(sample_organisation.id)
|
||||
assert invite['invited_by'] == str(sample_user.id)
|
||||
assert invite['id']
|
||||
|
||||
|
||||
def test_get_invited_users_by_service_with_no_invites(admin_request, sample_organisation):
|
||||
json_resp = admin_request.get(
|
||||
'organisation_invite.get_invited_org_users_by_organisation',
|
||||
organisation_id=sample_organisation.id
|
||||
)
|
||||
assert len(json_resp['data']) == 0
|
||||
|
||||
|
||||
def test_update_org_invited_user_set_status_to_cancelled(admin_request, sample_invited_org_user):
|
||||
data = {'status': 'cancelled'}
|
||||
|
||||
json_resp = admin_request.post(
|
||||
'organisation_invite.update_org_invite_status',
|
||||
organisation_id=sample_invited_org_user.organisation_id,
|
||||
invited_org_user_id=sample_invited_org_user.id,
|
||||
_data=data
|
||||
)
|
||||
assert json_resp['data']['status'] == 'cancelled'
|
||||
|
||||
|
||||
def test_update_org_invited_user_for_wrong_service_returns_404(admin_request, sample_invited_org_user, fake_uuid):
|
||||
data = {'status': 'cancelled'}
|
||||
|
||||
json_resp = admin_request.post(
|
||||
'organisation_invite.update_org_invite_status',
|
||||
organisation_id=fake_uuid,
|
||||
invited_org_user_id=sample_invited_org_user.id,
|
||||
_data=data,
|
||||
_expected_status=404
|
||||
)
|
||||
assert json_resp['message'] == 'No result found'
|
||||
|
||||
|
||||
def test_update_org_invited_user_for_invalid_data_returns_400(admin_request, sample_invited_org_user):
|
||||
data = {'status': 'garbage'}
|
||||
|
||||
json_resp = admin_request.post(
|
||||
'organisation_invite.update_org_invite_status',
|
||||
organisation_id=sample_invited_org_user.organisation_id,
|
||||
invited_org_user_id=sample_invited_org_user.id,
|
||||
_data=data,
|
||||
_expected_status=400
|
||||
)
|
||||
assert len(json_resp['errors']) == 1
|
||||
assert json_resp['errors'][0]['message'] == 'status garbage is not one of [pending, accepted, cancelled]'
|
||||
@@ -1,6 +1,8 @@
|
||||
import uuid
|
||||
|
||||
from app.models import Organisation
|
||||
from app.dao.organisation_dao import dao_add_service_to_organisation
|
||||
from tests.app.db import create_organisation, create_service
|
||||
from app.dao.organisation_dao import dao_add_service_to_organisation, dao_add_user_to_organisation
|
||||
from tests.app.db import create_organisation, create_service, create_user
|
||||
|
||||
|
||||
def test_get_all_organisations(admin_request, notify_db_session):
|
||||
@@ -270,3 +272,41 @@ def test_rest_get_organisation_services_inactive_services_at_end(
|
||||
assert response[0]['name'] == service.name
|
||||
assert response[1]['name'] == inactive_service.name
|
||||
assert response[2]['name'] == inactive_service_1.name
|
||||
|
||||
|
||||
def test_add_user_to_organisation_returns_added_user(admin_request, sample_organisation, sample_user):
|
||||
response = admin_request.post(
|
||||
'organisation.add_user_to_organisation',
|
||||
organisation_id=str(sample_organisation.id),
|
||||
user_id=str(sample_user.id),
|
||||
_expected_status=200
|
||||
)
|
||||
|
||||
assert response['data']['id'] == str(sample_user.id)
|
||||
assert len(response['data']['organisations']) == 1
|
||||
assert response['data']['organisations'][0] == str(sample_organisation.id)
|
||||
|
||||
|
||||
def test_add_user_to_organisation_returns_404_if_user_does_not_exist(admin_request, sample_organisation):
|
||||
admin_request.post(
|
||||
'organisation.add_user_to_organisation',
|
||||
organisation_id=str(sample_organisation.id),
|
||||
user_id=str(uuid.uuid4()),
|
||||
_expected_status=404
|
||||
)
|
||||
|
||||
|
||||
def test_get_organisation_users_returns_users_for_organisation(admin_request, sample_organisation):
|
||||
first = create_user(email='first@invited.com')
|
||||
second = create_user(email='another@invited.com')
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=first.id)
|
||||
dao_add_user_to_organisation(organisation_id=sample_organisation.id, user_id=second.id)
|
||||
|
||||
response = admin_request.get(
|
||||
'organisation.get_organisation_users',
|
||||
organisation_id=sample_organisation.id,
|
||||
_expected_status=200
|
||||
)
|
||||
|
||||
assert len(response['data']) == 2
|
||||
assert response['data'][0]['id'] == str(first.id)
|
||||
|
||||
@@ -104,7 +104,8 @@ def notify_db_session(notify_db):
|
||||
"dvla_organisation",
|
||||
"notification_status_types",
|
||||
"service_permission_types",
|
||||
"auth_type"]:
|
||||
"auth_type",
|
||||
"invite_status_type"]:
|
||||
notify_db.engine.execute(tbl.delete())
|
||||
notify_db.session.commit()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user