From b733fe1359424710b3301a01bc582ba8bd9bfbf7 Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Fri, 6 Sep 2019 14:02:28 +0200
Subject: [PATCH] gesbac: initial connector (#35325)

---
 passerelle/apps/gesbac/__init__.py            |   0
 .../apps/gesbac/migrations/0001_initial.py    |  57 ++
 passerelle/apps/gesbac/migrations/__init__.py |   0
 passerelle/apps/gesbac/models.py              | 490 ++++++++++++++++++
 passerelle/settings.py                        |   1 +
 passerelle/utils/__init__.py                  |   1 +
 tests/test_gesbac.py                          | 119 +++++
 7 files changed, 668 insertions(+)
 create mode 100644 passerelle/apps/gesbac/__init__.py
 create mode 100644 passerelle/apps/gesbac/migrations/0001_initial.py
 create mode 100644 passerelle/apps/gesbac/migrations/__init__.py
 create mode 100644 passerelle/apps/gesbac/models.py
 create mode 100644 tests/test_gesbac.py

diff --git a/passerelle/apps/gesbac/__init__.py b/passerelle/apps/gesbac/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/gesbac/migrations/0001_initial.py b/passerelle/apps/gesbac/migrations/0001_initial.py
new file mode 100644
index 00000000..b6f7da1a
--- /dev/null
+++ b/passerelle/apps/gesbac/migrations/0001_initial.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-09-16 16:38
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import jsonfield.fields
+import passerelle.utils.sftp
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0014_auto_20190820_0914'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CSV',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('formdata_id', models.CharField(max_length=64, null=True)),
+                ('creation_datetime', models.DateTimeField(auto_now_add=True)),
+                ('filename', models.CharField(max_length=128)),
+                ('sequence_number', models.IntegerField(default=0)),
+                ('file_type', models.CharField(choices=[(b'D', b'Demand'), (b'R', b'Response')], default=b'D', max_length=1)),
+                ('data', jsonfield.fields.JSONField(default=dict)),
+            ],
+            options={
+                'get_latest_by': 'creation_datetime',
+            },
+        ),
+        migrations.CreateModel(
+            name='Gesbac',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=50, verbose_name='Title')),
+                ('description', models.TextField(verbose_name='Description')),
+                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+                ('outcoming_sftp', passerelle.utils.sftp.SFTPField(default=None, verbose_name='Outcoming SFTP')),
+                ('incoming_sftp', passerelle.utils.sftp.SFTPField(default=None, verbose_name='Incoming SFTP')),
+                ('output_files_prefix', models.CharField(max_length=32, verbose_name='Output files prefix')),
+                ('input_files_prefix', models.CharField(max_length=32, verbose_name='Input files prefix')),
+                ('users', models.ManyToManyField(blank=True, related_name='_gesbac_users_+', related_query_name='+', to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'Gesbac',
+            },
+        ),
+        migrations.AddField(
+            model_name='csv',
+            name='resource',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='gesbac.Gesbac'),
+        ),
+    ]
diff --git a/passerelle/apps/gesbac/migrations/__init__.py b/passerelle/apps/gesbac/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/gesbac/models.py b/passerelle/apps/gesbac/models.py
new file mode 100644
index 00000000..591755d6
--- /dev/null
+++ b/passerelle/apps/gesbac/models.py
@@ -0,0 +1,490 @@
+# -*- coding: utf-8 -*-
+# Copyright (C) 2019  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import csv
+import os
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django.utils.timezone import now, make_aware
+from django.core.files.storage import default_storage
+
+from passerelle.base.models import BaseResource
+from passerelle.utils.api import endpoint, APIError
+from passerelle.utils import SFTPField
+
+from jsonfield import JSONField
+
+CSV_DELIMITER = ';'
+
+DEMAND_SCHEMA = {
+    "$schema": "http://json-schema.org/draft-03/schema#",
+    "title": "Gesbac",
+    "description": "",
+    "type": "object",
+    "properties": {
+        "formdata_id": {
+            "description": "formdata id",
+            "type": "string",
+            "required": True
+        },
+        "demand_date": {
+            "description": "demand date",
+            "type": "string",
+            "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
+            "required": True
+        },
+        "demand_time": {
+            "description": "demand time",
+            "type": "string",
+            "pattern": "^[0-9]{2}:[0-9]{2}:[0-9]{2}$",
+            "required": True
+        },
+        "producer_code": {
+            "description": "producer code",
+            "type": "integer",
+            "required": True
+        },
+        "invariant_number": {
+            "description": "invariant number",
+            "type": "string",
+            "required": False,
+            "maxLength": 10,
+            "default": ""
+        },
+        "city_insee_code": {
+            "type": "string",
+            "required": False,
+            "default": ""
+        },
+        "street_rivoli_code": {
+            "type": "string",
+            "required": False,
+            "default": ""
+        },
+        "street_name": {
+            "type": "string",
+            "required": False,
+            "default": ""
+        },
+        "address_complement": {
+            "type": "string",
+            "maxLength": 32,
+            "required": False,
+            "default": ""
+        },
+        "street_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "bis_ter": {
+            "type": "string",
+            "maxLength": 3,
+            "required": False,
+            "default": ""
+        },
+        "building": {
+            "type": "string",
+            "maxLength": 5,
+            "required": False,
+            "default": ""
+        },
+        "hall": {
+            "type": "string",
+            "maxLength": 5,
+            "required": False,
+            "default": ""
+        },
+        "appartment_number": {
+            "type": "string",
+            "maxLength": 5,
+            "required": False,
+            "default": ""
+        },
+        "producer_social_reason": {
+            "type": "string",
+            "maxLength": 38,
+            "required": False,
+            "default": ""
+        },
+        "producer_title_code": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "producer_last_name": {
+            "type": "string",
+            "maxLength": 38,
+            "required": False,
+            "default": ""
+        },
+        "producer_first_name": {
+            "type": "string",
+            "maxLength": 32,
+            "required": False,
+            "default": ""
+        },
+        "producer_phone": {
+            "type": "string",
+            "maxLength": 20,
+            "required": False,
+            "default": ""
+        },
+        "producer_email": {
+            "type": "string",
+            "maxLength": 50,
+            "required": False,
+            "default": ""
+        },
+        "owner_last_name": {
+            "type": "string",
+            "maxLength": 38,
+            "required": False,
+            "default": ""
+        },
+        "owner_first_name": {
+            "type": "string",
+            "maxLength": 32,
+            "required": False,
+            "default": ""
+        },
+        "owner_phone": {
+            "type": "string",
+            "maxLength": 20,
+            "required": False,
+            "default": ""
+        },
+        "owner_email": {
+            "type": "string",
+            "maxLength": 50,
+            "required": False,
+            "default": ""
+        },
+        "activity_code": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "family_members_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "houses_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "t1_flats_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "t2_flats_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "t3_flats_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "t4_flats_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "t5_flats_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "t6_flats_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "shops_number": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "garden_size": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "expected_date": {
+            "type": "string",
+            "pattern": "^[0-9]{8}$",
+            "required": False,
+            "default": ""
+        },
+        "expected_time": {
+            "type": "string",
+            "pattern": "^[0-9]{2}$",
+            "required": False,
+            "default": ""
+        },
+        "modification_code": {
+            "type": "integer",
+            "required": False,
+            "default": 0
+        },
+        "demand_reason_label": {
+            "type": "string",
+            "required": False,
+            "default": ""
+        },
+        "comment": {
+            "type": "string",
+            "maxLength": 500,
+            "required": False,
+            "default": ""
+        },
+        "card_subject": {
+            "type": "integer",
+            "required": True
+        },
+        "card_type": {
+            "type": "integer",
+            "required": True
+        },
+        "card_demand_reason": {
+            "type": "integer",
+            "required": True
+        },
+        "cards_number": {
+            "type": "integer",
+            "required": True,
+            "default": 1
+        },
+        "card_number": {
+            "type": "string",
+            "maxLength": 20,
+            "required": False
+        },
+        "card_bar_code": {
+            "type": "string",
+            "maxLength": 20,
+            "required": False,
+            "default": "",
+        },
+        "card_code": {
+            "type": "string",
+            "maxLength": 20,
+            "required": False,
+            "default": "",
+        },
+        "card_validity_start_date": {
+            "type": "string",
+            "required": False,
+            "pattern": "^[0-9]{8}$",
+            "default": "",
+        },
+        "card_validity_end_date": {
+            "type": "string",
+            "required": False,
+            "pattern": "^[0-9]{8}$",
+            "default": "",
+        },
+        "card_comment": {
+            "type": "string",
+            "required": False,
+            "maxLength": 100,
+            "default": "",
+        }
+    }
+}
+
+
+class Gesbac(BaseResource):
+    outcoming_sftp = SFTPField(verbose_name=_('Outcoming SFTP'))
+    incoming_sftp = SFTPField(verbose_name=_('Incoming SFTP'))
+    output_files_prefix = models.CharField(_('Output files prefix'),
+                                           blank=False, max_length=32)
+    input_files_prefix = models.CharField(_('Input files prefix'),
+                                          blank=False, max_length=32)
+
+    category = _('Business Process Connectors')
+
+    class Meta:
+        verbose_name = u'Gesbac'
+
+    def check_status(self):
+        with self.outcoming_sftp.client() as out_sftp:
+            out_sftp.listdir()
+        with self.incoming_sftp.client() as in_sftp:
+            in_sftp.listdir()
+
+    @property
+    def file_handler(self):
+        return FileHandler(resource=self)
+
+    @endpoint(name='create-demand',
+              perm='can_access',
+              description=_('Create demand'),
+              post = {
+                  'description': _('Creates a demand file'),
+                  'request_body': {
+                      'schema': {
+                          'application/json': DEMAND_SCHEMA
+                      }
+                  }
+              }
+    )
+    def create_demand(self, request, post_data):
+        return {'data': self.file_handler.send(post_data['formdata_id'], post_data)}
+
+    @endpoint(name='get-response', perm='can_access',
+              description=_('Get response'),
+              parameters={
+                  'formdata_id': {
+                      'description': _('Form data identifier'),
+                      'example_value': '42-01'
+                  }
+              }
+    )
+    def get_response(self, request, formdata_id):
+        return {'data': self.file_handler.get(formdata_id)}
+
+
+CSV_FILE_TYPES = (
+    ('D', 'Demand'),
+    ('R', 'Response')
+)
+
+
+class CSV(models.Model):
+    resource = models.ForeignKey(Gesbac)
+    formdata_id = models.CharField(max_length=64, null=True)
+    creation_datetime = models.DateTimeField(auto_now_add=True)
+    filename = models.CharField(max_length=128)
+    sequence_number = models.IntegerField(default=0)
+    file_type = models.CharField(max_length=1, choices=CSV_FILE_TYPES, default='D')
+    data = JSONField()
+
+    class Meta:
+        get_latest_by = 'creation_datetime'
+
+
+class FileHandler(object):
+
+    def __init__(self, resource):
+        self.resource = resource
+
+    def get_file_suffix(self, formdata_id, sequence_number):
+        sequence = "%03d" % sequence_number
+        # replace '-' from formdata id with sequence number because integer is
+        # expected
+        return formdata_id.replace('-', sequence)
+
+    def get_filename(self, prefix, formdata_id, sequence_number):
+        timestamp = now().strftime('%y%m%d-%H%M%S')
+        suffix = self.get_file_suffix(formdata_id, sequence_number)
+        return '%s%s-%s.csv' % (prefix, timestamp, suffix)
+
+    def send(self, formdata_id, data):
+
+        try:
+            demand_file = self.resource.csv_set.filter(formdata_id=formdata_id, file_type='D').latest()
+            sequence_number = demand_file.sequence_number + 1
+        except CSV.DoesNotExist:
+            sequence_number = 0
+
+        filename = self.get_filename(self.resource.output_files_prefix,
+                                     formdata_id, sequence_number)
+
+        with self.resource.outcoming_sftp.client() as client:
+            with client.open(filename, mode='w') as fd:
+                writer = csv.writer(fd, delimiter=CSV_DELIMITER)
+                applicant_data = ['E', formdata_id]
+                # get applicant attributes
+                for param in ['demand_date', 'demand_time', 'producer_code',
+                              'invariant_number', 'city_insee_code', 'street_rivoli_code',
+                              'street_name', 'address_complement', 'street_number',
+                              'bis_ter', 'building', 'hall', 'appartment_number',
+                              'producer_social_reason', 'producer_title_code',
+                              'producer_last_name', 'producer_first_name',
+                              'producer_phone', 'producer_email', 'owner_last_name',
+                              'owner_first_name', 'owner_phone', 'owner_email',
+                              'activity_code', 'family_members_number', 'houses_number',
+                              't1_flats_number', 't2_flats_number', 't3_flats_number',
+                              't4_flats_number', 't5_flats_number', 't6_flats_number',
+                              'shops_number', 'garden_size', 'expected_date',
+                              'expected_time', 'modification_code',
+                              'demand_reason_label', 'comment']:
+                    item = data.get(param)
+                    if isinstance(item, basestring):
+                        item = item.encode('utf-8')
+                    applicant_data.append(item)
+                writer.writerow(applicant_data)
+                # get card attributes
+                card_data = ['CARTE', formdata_id]
+                for param in ['card_demand_purpose', 'card_type', 'card_demand_reason',
+                             'card_demanded_number', 'card_number', 'card_bar_code',
+                             'card_code', 'card_validity_start_date',
+                             'card_validity_end_date', 'card_comment']:
+                    item = data.get(param)
+                    if isinstance(item, basestring):
+                        item = item.encode('utf-8')
+                    card_data.append(item)
+                writer.writerow(card_data)
+
+        CSV.objects.create(formdata_id=formdata_id,
+                           resource=self.resource,
+                           sequence_number=sequence_number,
+                           filename=filename,
+                           data=data)
+        return {'filename': filename}
+
+    def get(self, formdata_id):
+        data = []
+        try:
+            demand = self.resource.csv_set.filter(formdata_id=formdata_id,
+                                                  file_type='D').latest()
+        except CSV.DoesNotExist:
+            raise APIError('Unknown demand')
+
+        try:
+            response = self.resource.csv_set.filter(formdata_id=formdata_id,
+                                                    sequence_number=demand.sequence_number,
+                                                    file_type='R').latest()
+            return response.data
+        except CSV.DoesNotExist:
+            pass
+
+        with self.resource.incoming_sftp.client() as client:
+            response_filename = demand.filename.replace(self.resource.output_files_prefix,
+                                                        self.resource.input_files_prefix)
+            for csv_file in client.listdir():
+                if csv_file.endswith('%s.csv' % self.get_file_suffix(formdata_id,
+                                                                     demand.sequence_number)):
+                    response_filename = csv_file
+                    break
+            else:
+                raise APIError('Response doest not exist yet')
+
+            with client.open(response_filename, mode='r') as fd:
+                for row in csv.reader(fd, delimiter=CSV_DELIMITER):
+                    data.append(row)
+            CSV.objects.create(resource=self.resource, formdata_id=formdata_id,
+                               filename=response_filename,
+                               sequence_number=demand.sequence_number,
+                               file_type='R', data=data)
+        return data
diff --git a/passerelle/settings.py b/passerelle/settings.py
index d30235b6..b997becc 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -136,6 +136,7 @@ INSTALLED_APPS = (
     'passerelle.apps.family',
     'passerelle.apps.feeds',
     'passerelle.apps.gdc',
+    'passerelle.apps.gesbac',
     'passerelle.apps.jsondatastore',
     'passerelle.apps.sp_fr',
     'passerelle.apps.mobyt',
diff --git a/passerelle/utils/__init__.py b/passerelle/utils/__init__.py
index 62422242..1a7a12ea 100644
--- a/passerelle/utils/__init__.py
+++ b/passerelle/utils/__init__.py
@@ -337,3 +337,4 @@ def batch(iterable, size):
 # legacy import, other modules keep importing to_json from passerelle.utils
 from .jsonresponse import to_json
 from .soap import SOAPClient, SOAPTransport
+from .sftp import SFTPField, SFTP
diff --git a/tests/test_gesbac.py b/tests/test_gesbac.py
new file mode 100644
index 00000000..e2b67697
--- /dev/null
+++ b/tests/test_gesbac.py
@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+
+# tests/test_gesbac.py
+# Copyright (C) 2019  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import datetime
+import pytest
+
+from django.utils.timezone import now
+from .utils import make_resource
+
+from passerelle.utils import SFTP
+from passerelle.apps.gesbac.models import Gesbac, CSV
+
+
+@pytest.fixture
+def resource(db, sftpserver):
+    return make_resource(
+        Gesbac,
+        slug='test',
+        title='Gesbac',
+        description='gesbac',
+        outcoming_sftp=SFTP('sftp://foo:bar@{server.host}:{server.port}/output/'.format(server=sftpserver)),
+        incoming_sftp=SFTP('sftp://foo:bar@{server.host}:{server.port}/input/'.format(server=sftpserver)),
+        output_files_prefix='output-',
+        input_files_prefix='input-')
+
+
+def test_check_status(app, resource, sftpserver):
+    with sftpserver.serve_content({'input': {'test': 'content'},
+                                   'output': {'file': 'content'}}):
+        resource.check_status()
+
+
+def test_create_demand(app, resource, sftpserver, freezer):
+    assert resource.csv_set.count() == 0
+    timestamp = now()
+    payload = {
+        'formdata_id': '42-42',
+        'demand_date': timestamp.strftime('%Y-%m-%d'),
+        'demand_time': timestamp.strftime('%H:%M:%S'),
+        'producer_code': 1,
+        'city_insee_code': '75114',
+        'street_rivoli_code': '4',
+        'street_name': 'Château',
+        'producer_social_reason': 'SCOP',
+        'producer_last_name': 'Bar',
+        'producer_first_name': 'Foo',
+        'producer_email': 'foo@example.com',
+        'owner_last_name': 'Bar',
+        'owner_first_name': 'Foo',
+        'owner_email': 'foo@example.com',
+        'family_members_number': 5,
+        'houses_number': 1,
+        'card_type': 1,
+        'card_subject': 1,
+        'card_demand_reason': 1,
+        'card_demand_purpose': 1,
+        'cards_number': 1
+    }
+    with sftpserver.serve_content({'output': {'file': 'content'}}):
+        response = app.post_json('/gesbac/test/create-demand/', params=payload)
+        assert response.json['data']['filename'] == '%s%s-4200042.csv' % (resource.output_files_prefix,
+                                                                          timestamp.strftime('%y%m%d-%H%M%S'))
+        assert resource.csv_set.filter(formdata_id='42-42', file_type='D').count() == 1
+        csv = resource.csv_set.filter(formdata_id='42-42', file_type='D').latest()
+        assert csv.filename == 'output-' + timestamp.strftime('%y%m%d-%H%M%S') + '-4200042.csv'
+        freezer.move_to(datetime.timedelta(seconds=5))
+        timestamp = now()
+        response = app.post_json('/gesbac/test/create-demand/', params=payload)
+        payload['cards_number'] = 2
+        assert resource.csv_set.filter(formdata_id='42-42', file_type='D').count() == 2
+        csv = resource.csv_set.filter(formdata_id='42-42', file_type='D').latest()
+        assert csv.filename == '%s%s-4200142.csv' % (resource.output_files_prefix,
+                                                      timestamp.strftime('%y%m%d-%H%M%S'))
+
+
+def test_get_demand_response(app, resource, sftpserver):
+    response = app.get('/gesbac/test/get-response/', params={'formdata_id': '42-42'})
+    assert response.json['err'] == 1
+    assert response.json['err_desc'] == 'Unknown demand'
+    assert response.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
+
+    demand_filename = '%s91001-090000-4200042.csv' % resource.output_files_prefix
+    CSV.objects.create(resource=resource, formdata_id='42-42',
+                       filename=demand_filename)
+    response_filename = '%s91001-090300-4200042.csv' % resource.input_files_prefix
+    CSV.objects.create(resource=resource, formdata_id='42-42',
+                       filename=response_filename, file_type='R', data=['CARTE', '42-42'])
+    response = app.get('/gesbac/test/get-response/', params={'formdata_id': '42-42'})
+    assert response.json['err'] == 0
+    assert response.json['data'] == ['CARTE', '42-42']
+
+    CSV.objects.filter(resource=resource, file_type='R').delete()
+
+    with sftpserver.serve_content({'input': {'test': 'content'}}):
+        response = app.get('/gesbac/test/get-response/', params={'formdata_id': '42-42'})
+        assert response.json['err'] == 1
+        assert response.json['err_desc'] == 'Response doest not exist yet'
+        assert response.json['err_class'] == 'passerelle.utils.jsonresponse.APIError'
+
+    with sftpserver.serve_content({'input': {response_filename: 'CARTE;42-42;3;2;;;;;;;;;'}}):
+        response = app.get('/gesbac/test/get-response/', params={'formdata_id': '42-42'})
+        assert response.json['err'] == 0
+        assert response.json['data'] == [['CARTE', '42-42', '3', '2', '', '', '', '', '', '', '', '', '']]
+        assert resource.csv_set.filter(file_type='R', filename=response_filename).count() == 1
-- 
2.23.0

