mirror of
https://github.com/sartography/cr-connect-workflow.git
synced 2025-02-22 20:58:28 +00:00
Merge pull request #27 from sartography/feature/manual_task_with_docs
Feature/manual task with docs
This commit is contained in:
commit
233a8b4ff9
@ -133,7 +133,7 @@ def update_task(workflow_id, task_id, body):
|
||||
processor = WorkflowProcessor(workflow_model)
|
||||
task_id = uuid.UUID(task_id)
|
||||
task = processor.bpmn_workflow.get_task(task_id)
|
||||
task.data = body
|
||||
task.update_data(body)
|
||||
processor.complete_task(task)
|
||||
processor.do_engine_steps()
|
||||
workflow_model.last_completed_task_id = task.id
|
||||
|
@ -74,6 +74,7 @@ class FileModel(db.Model):
|
||||
content_type = db.Column(db.String)
|
||||
is_reference = db.Column(db.Boolean, nullable=False, default=False) # A global reference file.
|
||||
primary = db.Column(db.Boolean, nullable=False, default=False) # Is this the primary BPMN in a workflow?
|
||||
primary_process_id = db.Column(db.String, nullable=True) # An id in the xml of BPMN documents, critical for primary BPMN.
|
||||
workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id'), nullable=True)
|
||||
workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=True)
|
||||
study_id = db.Column(db.Integer, db.ForeignKey('study.id'), nullable=True)
|
||||
|
@ -29,7 +29,6 @@ class WorkflowSpecModel(db.Model):
|
||||
display_name = db.Column(db.String)
|
||||
display_order = db.Column(db.Integer, nullable=True)
|
||||
description = db.Column(db.Text)
|
||||
primary_process_id = db.Column(db.String)
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('workflow_spec_category.id'), nullable=True)
|
||||
category = db.relationship("WorkflowSpecCategoryModel")
|
||||
is_master_spec = db.Column(db.Boolean, default=False)
|
||||
|
@ -26,15 +26,12 @@ class FileService(object):
|
||||
workflow_spec_id=workflow_spec.id,
|
||||
name=name,
|
||||
primary=primary,
|
||||
is_status=is_status
|
||||
is_status=is_status,
|
||||
)
|
||||
if primary:
|
||||
bpmn: ElementTree.Element = ElementTree.fromstring(binary_data)
|
||||
workflow_spec.primary_process_id = WorkflowProcessor.get_process_id(bpmn)
|
||||
print("Locating Process Id for " + name + " " + workflow_spec.primary_process_id)
|
||||
|
||||
return FileService.update_file(file_model, binary_data, content_type)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def add_form_field_file(study_id, workflow_id, task_id, form_field_key, name, content_type, binary_data):
|
||||
"""Create a new file and associate it with a user task form field within a workflow.
|
||||
@ -141,6 +138,11 @@ class FileService(object):
|
||||
else:
|
||||
version = file_data_model.version + 1
|
||||
|
||||
# If this is a BPMN, extract the process id.
|
||||
if file_model.type == FileType.bpmn:
|
||||
bpmn: ElementTree.Element = ElementTree.fromstring(binary_data)
|
||||
file_model.primary_process_id = WorkflowProcessor.get_process_id(bpmn)
|
||||
|
||||
file_model.latest_version = version
|
||||
file_data_model = FileDataModel(data=binary_data, file_model=file_model, version=version,
|
||||
md5_hash=md5_checksum, last_updated=datetime.now())
|
||||
@ -184,6 +186,7 @@ class FileService(object):
|
||||
.filter(FileDataModel.version == file_model.latest_version) \
|
||||
.first()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_reference_file_data(file_name):
|
||||
file_model = session.query(FileModel). \
|
||||
@ -222,8 +225,8 @@ class FileService(object):
|
||||
# then we can look it up. As there is the potential for sub-workflows, we
|
||||
# may need to travel up to locate the primary process.
|
||||
spec = workflow.spec
|
||||
workflow_model = session.query(WorkflowSpecModel). \
|
||||
filter(WorkflowSpecModel.primary_process_id == spec.name).first()
|
||||
workflow_model = session.query(WorkflowSpecModel).join(FileModel). \
|
||||
filter(FileModel.primary_process_id == spec.name).first()
|
||||
if workflow_model is None and workflow != workflow.outer_workflow:
|
||||
return FileService.__find_spec_model_in_db(workflow.outer_workflow)
|
||||
|
||||
|
@ -276,6 +276,8 @@ class WorkflowProcessor(object):
|
||||
def populate_form_with_random_data(task):
|
||||
"""populates a task with random data - useful for testing a spec."""
|
||||
|
||||
if not hasattr(task.task_spec, 'form'): return
|
||||
|
||||
form_data = {}
|
||||
for field in task.task_spec.form.fields:
|
||||
if field.type == "enum":
|
||||
|
@ -45,14 +45,13 @@ class WorkflowService(object):
|
||||
|
||||
@staticmethod
|
||||
def spiff_task_to_api_task(spiff_task):
|
||||
documentation = spiff_task.task_spec.documentation if hasattr(spiff_task.task_spec, "documentation") else ""
|
||||
task = Task(spiff_task.id,
|
||||
spiff_task.task_spec.name,
|
||||
spiff_task.task_spec.description,
|
||||
spiff_task.task_spec.__class__.__name__,
|
||||
spiff_task.get_state_name(),
|
||||
None,
|
||||
documentation,
|
||||
"",
|
||||
spiff_task.data)
|
||||
|
||||
# Only process the form and documentation if this is something that is ready or completed.
|
||||
@ -62,21 +61,33 @@ class WorkflowService(object):
|
||||
for field in task.form.fields:
|
||||
WorkflowService._process_options(spiff_task, field)
|
||||
|
||||
if documentation != "" and documentation is not None:
|
||||
WorkflowService._process_documentation(task, documentation)
|
||||
task.documentation = WorkflowService._process_documentation(spiff_task)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def _process_documentation(task, documentation):
|
||||
def _process_documentation(spiff_task):
|
||||
"""Runs the given documentation string through the Jinja2 processor to inject data
|
||||
create loops, etc..."""
|
||||
create loops, etc... - If a markdown file exists with the same name as the task id,
|
||||
it will use that file instead of the documentation. """
|
||||
|
||||
documentation = spiff_task.task_spec.documentation if hasattr(spiff_task.task_spec, "documentation") else ""
|
||||
|
||||
try:
|
||||
template = Template(documentation)
|
||||
task.documentation = template.render(**task.data)
|
||||
doc_file_name = spiff_task.task_spec.name + ".md"
|
||||
data_model = FileService.get_workflow_file_data(spiff_task.workflow, doc_file_name)
|
||||
raw_doc = data_model.data.decode("utf-8")
|
||||
except ApiError:
|
||||
raw_doc = documentation
|
||||
|
||||
if not raw_doc:
|
||||
return ""
|
||||
|
||||
try:
|
||||
template = Template(raw_doc)
|
||||
return template.render(**spiff_task.data)
|
||||
except jinja2.exceptions.TemplateError as ue:
|
||||
raise ApiError(code="template_error", message="Error processing template for task %s: %s" %
|
||||
(task.name, str(ue)), status_code=500)
|
||||
(spiff_task.task_spec.name, str(ue)), status_code=500)
|
||||
# TODO: Catch additional errors and report back.
|
||||
|
||||
@staticmethod
|
||||
|
30
migrations/versions/476f8a4933ba_.py
Normal file
30
migrations/versions/476f8a4933ba_.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 476f8a4933ba
|
||||
Revises: 7be7cecbeea8
|
||||
Create Date: 2020-04-17 12:10:38.962672
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '476f8a4933ba'
|
||||
down_revision = '7be7cecbeea8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('file', sa.Column('primary_process_id', sa.String(), nullable=True))
|
||||
op.drop_column('workflow_spec', 'primary_process_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('workflow_spec', sa.Column('primary_process_id', sa.VARCHAR(), autoincrement=False, nullable=True))
|
||||
op.drop_column('file', 'primary_process_id')
|
||||
# ### end Alembic commands ###
|
@ -1 +0,0 @@
|
||||
,dan,lilmaker,15.04.2020 11:05,file:///home/dan/.config/libreoffice/4;
|
@ -0,0 +1,6 @@
|
||||
# This is Markdown
|
||||
* It should be processed.
|
||||
* It will display some information from the Previous Form using Jinja Syntax.
|
||||
|
||||
You entered the following information on the previous page:
|
||||
Your name is {{ name }}
|
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" id="Definitions_1v1rp1q" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.4.1">
|
||||
<bpmn:process id="Process_1vu5nxl" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>SequenceFlow_0lvudp8</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_0lvudp8" sourceRef="StartEvent_1" targetRef="Task_Form" />
|
||||
<bpmn:endEvent id="EndEvent_0q4qzl9">
|
||||
<bpmn:incoming>SequenceFlow_02vev7n</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_02vev7n" sourceRef="Task_Manual_One" targetRef="EndEvent_0q4qzl9" />
|
||||
<bpmn:manualTask id="Task_Manual_One" name="Manual Task with Documentation">
|
||||
<bpmn:incoming>SequenceFlow_1n97kpy</bpmn:incoming>
|
||||
<bpmn:outgoing>SequenceFlow_02vev7n</bpmn:outgoing>
|
||||
</bpmn:manualTask>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_1n97kpy" sourceRef="Task_Form" targetRef="Task_Manual_One" />
|
||||
<bpmn:userTask id="Task_Form" name="Name Form" camunda:formKey="my_form">
|
||||
<bpmn:extensionElements>
|
||||
<camunda:formData>
|
||||
<camunda:formField id="name" label="Please Enter you Name:" type="string" />
|
||||
</camunda:formData>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>SequenceFlow_0lvudp8</bpmn:incoming>
|
||||
<bpmn:outgoing>SequenceFlow_1n97kpy</bpmn:outgoing>
|
||||
</bpmn:userTask>
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1vu5nxl">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_0lvudp8_di" bpmnElement="SequenceFlow_0lvudp8">
|
||||
<di:waypoint x="215" y="117" />
|
||||
<di:waypoint x="240" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="EndEvent_0q4qzl9_di" bpmnElement="EndEvent_0q4qzl9">
|
||||
<dc:Bounds x="532" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_02vev7n_di" bpmnElement="SequenceFlow_02vev7n">
|
||||
<di:waypoint x="480" y="117" />
|
||||
<di:waypoint x="532" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="ManualTask_0nc8sr9_di" bpmnElement="Task_Manual_One">
|
||||
<dc:Bounds x="380" y="77" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_1n97kpy_di" bpmnElement="SequenceFlow_1n97kpy">
|
||||
<di:waypoint x="340" y="117" />
|
||||
<di:waypoint x="380" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="UserTask_1qhb92h_di" bpmnElement="Task_Form">
|
||||
<dc:Bounds x="240" y="77" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
@ -12,6 +12,11 @@ from tests.base_test import BaseTest
|
||||
|
||||
class TestFilesApi(BaseTest):
|
||||
|
||||
def minimal_bpmn(self, content):
|
||||
"""Returns a bytesIO object of a well formed BPMN xml file with some string content of your choosing."""
|
||||
minimal_dbpm = "<x><process id='1' isExecutable='false'><startEvent id='a'/></process>%s</x>"
|
||||
return (minimal_dbpm % content).encode()
|
||||
|
||||
def test_list_files_for_workflow_spec(self):
|
||||
self.load_example_data()
|
||||
spec_id = 'core_info'
|
||||
@ -145,13 +150,13 @@ class TestFilesApi(BaseTest):
|
||||
self.load_example_data()
|
||||
spec = session.query(WorkflowSpecModel).first()
|
||||
data = {}
|
||||
data['file'] = io.BytesIO(b"abcdef"), 'my_new_file.bpmn'
|
||||
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
|
||||
rv = self.app.post('/v1.0/file?workflow_spec_id=%s' % spec.id, data=data, follow_redirects=True,
|
||||
content_type='multipart/form-data', headers=self.logged_in_headers())
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
file = FileModelSchema().load(json_data, session=session)
|
||||
|
||||
data['file'] = io.BytesIO(b"hijklim"), 'my_new_file.bpmn'
|
||||
data['file'] = io.BytesIO(self.minimal_bpmn("efghijk")), 'my_new_file.bpmn'
|
||||
rv = self.app.put('/v1.0/file/%i/data' % file.id, data=data, follow_redirects=True,
|
||||
content_type='multipart/form-data', headers=self.logged_in_headers())
|
||||
self.assert_success(rv)
|
||||
@ -172,20 +177,20 @@ class TestFilesApi(BaseTest):
|
||||
self.assert_success(rv)
|
||||
data = rv.get_data()
|
||||
self.assertIsNotNone(data)
|
||||
self.assertEqual(b"hijklim", data)
|
||||
self.assertEqual(self.minimal_bpmn("efghijk"), data)
|
||||
|
||||
def test_update_with_same_exact_data_does_not_increment_version(self):
|
||||
self.load_example_data()
|
||||
spec = session.query(WorkflowSpecModel).first()
|
||||
data = {}
|
||||
data['file'] = io.BytesIO(b"abcdef"), 'my_new_file.bpmn'
|
||||
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
|
||||
rv = self.app.post('/v1.0/file?workflow_spec_id=%s' % spec.id, data=data, follow_redirects=True,
|
||||
content_type='multipart/form-data', headers=self.logged_in_headers())
|
||||
self.assertIsNotNone(rv.get_data())
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
file = FileModelSchema().load(json_data, session=session)
|
||||
self.assertEqual(1, file.latest_version)
|
||||
data['file'] = io.BytesIO(b"abcdef"), 'my_new_file.bpmn'
|
||||
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
|
||||
rv = self.app.put('/v1.0/file/%i/data' % file.id, data=data, follow_redirects=True,
|
||||
content_type='multipart/form-data', headers=self.logged_in_headers())
|
||||
self.assertIsNotNone(rv.get_data())
|
||||
@ -213,3 +218,34 @@ class TestFilesApi(BaseTest):
|
||||
rv = self.app.get('/v1.0/file/%i' % file.id, headers=self.logged_in_headers())
|
||||
self.assertEqual(404, rv.status_code)
|
||||
|
||||
def test_change_primary_bpmn(self):
|
||||
self.load_example_data()
|
||||
spec = session.query(WorkflowSpecModel).first()
|
||||
data = {}
|
||||
data['file'] = io.BytesIO(self.minimal_bpmn("abcdef")), 'my_new_file.bpmn'
|
||||
|
||||
# Add a new BPMN file to the specification
|
||||
rv = self.app.post('/v1.0/file?workflow_spec_id=%s' % spec.id, data=data, follow_redirects=True,
|
||||
content_type='multipart/form-data', headers=self.logged_in_headers())
|
||||
self.assert_success(rv)
|
||||
self.assertIsNotNone(rv.get_data())
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
file = FileModelSchema().load(json_data, session=session)
|
||||
|
||||
# Delete the primary BPMN file for the workflow.
|
||||
orig_model = session.query(FileModel).\
|
||||
filter(FileModel.primary == True).\
|
||||
filter(FileModel.workflow_spec_id == spec.id).first()
|
||||
rv = self.app.delete('/v1.0/file?file_id=%s' % orig_model.id, headers=self.logged_in_headers())
|
||||
|
||||
|
||||
# Set that new file to be the primary BPMN, assure it has a primary_process_id
|
||||
file.primary = True
|
||||
rv = self.app.put('/v1.0/file/%i' % file.id,
|
||||
content_type="application/json",
|
||||
data=json.dumps(FileModelSchema().dump(file)), headers=self.logged_in_headers())
|
||||
self.assert_success(rv)
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
self.assertTrue(json_data['primary'])
|
||||
self.assertIsNotNone(json_data['primary_process_id'])
|
||||
|
||||
|
@ -243,3 +243,17 @@ class TestTasksApi(BaseTest):
|
||||
self.assertGreater(db_stats_after.num_tasks_total, 0)
|
||||
self.assertEqual(db_stats_after.num_tasks_total,
|
||||
db_stats_after.num_tasks_complete + db_stats_after.num_tasks_incomplete)
|
||||
|
||||
def test_manual_task_with_external_documentation(self):
|
||||
self.load_example_data()
|
||||
workflow = self.create_workflow('manual_task_with_external_documentation')
|
||||
|
||||
# get the first form in the two form workflow.
|
||||
tasks = self.get_workflow_api(workflow).user_tasks
|
||||
workflow_api = self.complete_form(workflow, tasks[0], {"name": "Dan"})
|
||||
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
self.assertEquals('Task_Manual_One', workflow.next_task['name'])
|
||||
self.assertEquals('ManualTask', workflow_api.next_task['type'])
|
||||
self.assertTrue('Markdown' in workflow_api.next_task['documentation'])
|
||||
self.assertTrue('Dan' in workflow_api.next_task['documentation'])
|
||||
|
@ -15,15 +15,20 @@ from tests.base_test import BaseTest
|
||||
class TestWorkflowService(BaseTest):
|
||||
|
||||
def test_documentation_processing_handles_replacements(self):
|
||||
self.load_example_data()
|
||||
workflow = self.create_workflow('random_fact')
|
||||
processor = WorkflowProcessor(workflow)
|
||||
processor.do_engine_steps()
|
||||
|
||||
docs = "Some simple docs"
|
||||
task = Task(1, "bill", "bill", "", "started", {}, docs, {})
|
||||
WorkflowService._process_documentation(task, docs)
|
||||
self.assertEqual(docs, task.documentation)
|
||||
task = processor.next_task()
|
||||
task.task_spec.documentation = "Some simple docs"
|
||||
docs = WorkflowService._process_documentation(task)
|
||||
self.assertEqual("Some simple docs", docs)
|
||||
|
||||
task.data = {"replace_me": "new_thing"}
|
||||
WorkflowService._process_documentation(task, "{{replace_me}}")
|
||||
self.assertEqual("new_thing", task.documentation)
|
||||
task.task_spec.documentation = "{{replace_me}}"
|
||||
docs = WorkflowService._process_documentation(task)
|
||||
self.assertEqual("new_thing", docs)
|
||||
|
||||
documentation = """
|
||||
# Bigger Test
|
||||
@ -41,19 +46,25 @@ class TestWorkflowService(BaseTest):
|
||||
|
||||
# other stuff.
|
||||
"""
|
||||
WorkflowService._process_documentation(task,(documentation))
|
||||
self.assertEqual(expected, task.documentation)
|
||||
task.task_spec.documentation = documentation
|
||||
result = WorkflowService._process_documentation(task)
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_documentation_processing_handles_conditionals(self):
|
||||
|
||||
docs = "This test {% if works == 'yes' %}works{% endif %}"
|
||||
task = Task(1, "bill", "bill", "", "started", {}, docs, {})
|
||||
WorkflowService._process_documentation(task, docs)
|
||||
self.assertEqual("This test ", task.documentation)
|
||||
self.load_example_data()
|
||||
workflow = self.create_workflow('random_fact')
|
||||
processor = WorkflowProcessor(workflow)
|
||||
processor.do_engine_steps()
|
||||
|
||||
task = processor.next_task()
|
||||
task.task_spec.documentation = "This test {% if works == 'yes' %}works{% endif %}"
|
||||
docs = WorkflowService._process_documentation(task)
|
||||
self.assertEqual("This test ", docs)
|
||||
|
||||
task.data = {"works": 'yes'}
|
||||
WorkflowService._process_documentation(task, docs)
|
||||
self.assertEqual("This test works", task.documentation)
|
||||
docs = WorkflowService._process_documentation(task)
|
||||
self.assertEqual("This test works", docs)
|
||||
|
||||
def test_enum_options_from_file(self):
|
||||
self.load_example_data()
|
||||
|
@ -1,7 +1,6 @@
|
||||
import json
|
||||
|
||||
from crc import session
|
||||
from crc.api.common import ApiErrorSchema
|
||||
from crc.models.file import FileModel
|
||||
from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel, WorkflowSpecCategoryModel
|
||||
from tests.base_test import BaseTest
|
||||
@ -24,7 +23,7 @@ class TestWorkflowSpec(BaseTest):
|
||||
self.assertEqual(spec.description, spec2.description)
|
||||
|
||||
def test_add_new_workflow_specification(self):
|
||||
self.load_example_data();
|
||||
self.load_example_data()
|
||||
num_before = session.query(WorkflowSpecModel).count()
|
||||
spec = WorkflowSpecModel(id='make_cookies', name='make_cookies', display_name='Cooooookies',
|
||||
description='Om nom nom delicious cookies')
|
||||
|
Loading…
x
Reference in New Issue
Block a user