Skip to content

Commit 64adc5e

Browse files
authored
Merge pull request #27 from ntoll/sync_logic
Sync logic
2 parents a645f50 + 45d5106 commit 64adc5e

File tree

7 files changed

+515
-33
lines changed

7 files changed

+515
-33
lines changed

Pipfile

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ python_version = "3.5"
99
[packages]
1010
SQLALchemy = "*"
1111
alembic = "*"
12+
securedrop-sdk = {git = "https://github.com/freedomofpress/securedrop-sdk.git"}
13+
"pathlib2" = "*"
1214

1315
[dev-packages]
1416
pytest = "*"

Pipfile.lock

+15-21
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

alembic/versions/fe7656c21eaa_add_remainder_of_database_models.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ def upgrade():
3838
op.create_table(
3939
'replies',
4040
sa.Column('id', sa.Integer(), nullable=False),
41+
sa.Column('uuid', sa.String(length=36), nullable=False),
4142
sa.Column('source_id', sa.Integer(), nullable=True),
4243
sa.Column('journalist_id', sa.Integer(), nullable=True),
4344
sa.Column('filename', sa.String(length=255), nullable=False),
4445
sa.Column('size', sa.Integer(), nullable=False),
4546
sa.ForeignKeyConstraint(['journalist_id'], ['users.id'], ),
4647
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ),
47-
sa.PrimaryKeyConstraint('id')
48+
sa.PrimaryKeyConstraint('id'),
49+
sa.UniqueConstraint('uuid')
4850
)
4951
op.create_table(
5052
'submissions',

securedrop_client/models.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,18 @@ class Source(Base):
2424
is_starred = Column(Boolean, server_default="false")
2525
last_updated = Column(DateTime)
2626

27-
def __init__(self, uuid, journalist_designation):
27+
def __init__(self, uuid, journalist_designation, is_flagged, public_key,
28+
interaction_count, is_starred, last_updated):
2829
self.uuid = uuid
2930
self.journalist_designation = journalist_designation
31+
self.is_flagged = is_flagged
32+
self.public_key = public_key
33+
self.interaction_count = interaction_count
34+
self.is_starred = is_starred
35+
self.last_updated = last_updated
3036

3137
def __repr__(self):
32-
return '<Source %r>' % (self.journalist_designation)
38+
return '<Source {}>'.format(self.journalist_designation)
3339

3440

3541
class Submission(Base):
@@ -56,12 +62,13 @@ def __init__(self, source, uuid, filename):
5662
self.filename = filename
5763

5864
def __repr__(self):
59-
return '<Submission %r>' % (self.filename)
65+
return '<Submission {}>'.format(self.filename)
6066

6167

6268
class Reply(Base):
6369
__tablename__ = 'replies'
6470
id = Column(Integer, primary_key=True)
71+
uuid = Column(String(36), unique=True, nullable=False)
6572
source_id = Column(Integer, ForeignKey('sources.id'))
6673
source = relationship("Source",
6774
backref=backref("replies", order_by=id,
@@ -74,24 +81,24 @@ class Reply(Base):
7481
filename = Column(String(255), nullable=False)
7582
size = Column(Integer, nullable=False)
7683

77-
def __init__(self, journalist, source, filename, size):
84+
def __init__(self, uuid, journalist, source, filename, size):
85+
self.uuid = uuid
7886
self.journalist_id = journalist.id
7987
self.source_id = source.id
8088
self.filename = filename
8189
self.size = size
8290

8391
def __repr__(self):
84-
return '<Reply %r>' % (self.filename)
92+
return '<Reply {}>'.format(self.filename)
8593

8694

8795
class User(Base):
8896
__tablename__ = 'users'
8997
id = Column(Integer, primary_key=True)
90-
uuid = Column(Integer, nullable=False, unique=True)
9198
username = Column(String(255), nullable=False, unique=True)
9299

93100
def __init__(self, username):
94101
self.username = username
95102

96103
def __repr__(self):
97-
return "<Journalist: {}".format(self.username)
104+
return "<Journalist: {}>".format(self.username)

securedrop_client/storage.py

+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""
2+
Functions needed to synchronise data with the SecureDrop server (via the
3+
securedrop_sdk). Each function requires but two arguments: an authenticated
4+
instance of the securedrop_sdk API class, and a SQLAlchemy session to the local
5+
database.
6+
7+
Copyright (C) 2018 The Freedom of the Press Foundation.
8+
9+
This program is free software: you can redistribute it and/or modify
10+
it under the terms of the GNU Affero General Public License as published
11+
by the Free Software Foundation, either version 3 of the License, or
12+
(at your option) any later version.
13+
14+
This program is distributed in the hope that it will be useful,
15+
but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
GNU Affero General Public License for more details.
18+
19+
You should have received a copy of the GNU Affero General Public License
20+
along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
"""
22+
import logging
23+
from dateutil.parser import parse
24+
from securedrop_client.models import Source, Submission, Reply, User
25+
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
def sync_with_api(api, session):
31+
"""
32+
Synchronises sources and submissions from the remote server's API.
33+
"""
34+
remote_submissions = []
35+
try:
36+
remote_sources = api.get_sources()
37+
for source in remote_sources:
38+
remote_submissions.extend(api.get_submissions(source))
39+
remote_replies = api.get_all_replies()
40+
except Exception as ex:
41+
# Log any errors but allow the caller to handle the exception.
42+
logger.error(ex)
43+
raise(ex)
44+
logger.info('Fetched {} remote sources.'.format(len(remote_sources)))
45+
logger.info('Fetched {} remote submissions.'.format(
46+
len(remote_submissions)))
47+
logger.info('Fetched {} remote replies.'.format(len(remote_replies)))
48+
local_sources = session.query(Source)
49+
local_submissions = session.query(Submission)
50+
local_replies = session.query(Reply)
51+
update_sources(remote_sources, local_sources, session)
52+
update_submissions(remote_submissions, local_submissions, session)
53+
update_replies(remote_replies, local_replies, session)
54+
55+
56+
def update_sources(remote_sources, local_sources, session):
57+
"""
58+
Given collections of remote sources, the current local sources and a
59+
session to the local database, ensure the state of the local database
60+
matches that of the remote sources:
61+
62+
* Existing items are updated in the local database.
63+
* New items are created in the local database.
64+
* Local items not returned in the remote sources are deleted from the
65+
local database.
66+
"""
67+
local_uuids = {source.uuid for source in local_sources}
68+
for source in remote_sources:
69+
if source.uuid in local_uuids:
70+
# Update an existing record.
71+
local_source = [s for s in local_sources
72+
if s.uuid == source.uuid][0]
73+
local_source.journalist_designation = source.journalist_designation
74+
local_source.is_flagged = source.is_flagged
75+
local_source.public_key = source.key
76+
local_source.interaction_count = source.interaction_count
77+
local_source.is_starred = source.is_starred
78+
local_source.last_updated = parse(source.last_updated)
79+
# Removing the UUID from local_uuids ensures this record won't be
80+
# deleted at the end of this function.
81+
local_uuids.remove(source.uuid)
82+
logger.info('Updated source {}'.format(source.uuid))
83+
else:
84+
# A new source to be added to the database.
85+
ns = Source(uuid=source.uuid,
86+
journalist_designation=source.journalist_designation,
87+
is_flagged=source.is_flagged,
88+
public_key=source.key,
89+
interaction_count=source.interaction_count,
90+
is_starred=source.is_starred,
91+
last_updated=parse(source.last_updated))
92+
session.add(ns)
93+
logger.info('Added new source {}'.format(source.uuid))
94+
# The uuids remaining in local_uuids do not exist on the remote server, so
95+
# delete the related records.
96+
for deleted_source in [s for s in local_sources if s.uuid in local_uuids]:
97+
session.delete(deleted_source)
98+
logger.info('Deleted source {}'.format(deleted_source.uuid))
99+
session.commit()
100+
101+
102+
def update_submissions(remote_submissions, local_submissions, session):
103+
"""
104+
* Existing submissions are updated in the local database.
105+
* New submissions have an entry created in the local database.
106+
* Local submissions not returned in the remote submissions are deleted
107+
from the local database.
108+
"""
109+
local_uuids = {submission.uuid for submission in local_submissions}
110+
for submission in remote_submissions:
111+
if submission.uuid in local_uuids:
112+
# Update an existing record.
113+
local_submission = [s for s in local_submissions
114+
if s.uuid == submission.uuid][0]
115+
local_submission.filename = submission.filename
116+
local_submission.size = submission.size
117+
local_submission.is_read = submission.is_read
118+
# Removing the UUID from local_uuids ensures this record won't be
119+
# deleted at the end of this function.
120+
local_uuids.remove(submission.uuid)
121+
logger.info('Updated submission {}'.format(submission.uuid))
122+
else:
123+
# A new submission to be added to the database.
124+
_, source_uuid = submission.source_url.rsplit('/', 1)
125+
source = session.query(Source).filter_by(uuid=source_uuid)[0]
126+
ns = Submission(source=source, uuid=submission.uuid,
127+
filename=submission.filename)
128+
session.add(ns)
129+
logger.info('Added new submission {}'.format(submission.uuid))
130+
# The uuids remaining in local_uuids do not exist on the remote server, so
131+
# delete the related records.
132+
for deleted_submission in [s for s in local_submissions
133+
if s.uuid in local_uuids]:
134+
session.delete(deleted_submission)
135+
logger.info('Deleted submission {}'.format(deleted_submission.uuid))
136+
session.commit()
137+
138+
139+
def update_replies(remote_replies, local_replies, session):
140+
"""
141+
* Existing replies are updated in the local database.
142+
* New replies have an entry created in the local database.
143+
* Local replies not returned in the remote replies are deleted from the
144+
local database.
145+
146+
If a reply references a new journalist username, add them to the database
147+
as a new user.
148+
"""
149+
local_uuids = {reply.uuid for reply in local_replies}
150+
for reply in remote_replies:
151+
if reply.uuid in local_uuids:
152+
# Update an existing record.
153+
local_reply = [r for r in local_replies if r.uuid == reply.uuid][0]
154+
user = find_or_create_user(reply.journalist_username, session)
155+
local_reply.journalist_id = user.id
156+
local_reply.filename = reply.filename
157+
local_reply.size = reply.size
158+
local_uuids.remove(reply.uuid)
159+
logger.info('Updated reply {}'.format(reply.uuid))
160+
else:
161+
# A new reply to be added to the database.
162+
source_uuid = reply.source_uuid
163+
source = session.query(Source).filter_by(uuid=source_uuid)[0]
164+
user = find_or_create_user(reply.journalist_username, session)
165+
nr = Reply(reply.uuid, user, source, reply.filename, reply.size)
166+
session.add(nr)
167+
logger.info('Added new reply {}'.format(reply.uuid))
168+
# The uuids remaining in local_uuids do not exist on the remote server, so
169+
# delete the related records.
170+
for deleted_reply in [r for r in local_replies if r.uuid in local_uuids]:
171+
session.delete(deleted_reply)
172+
logger.info('Deleted reply {}'.format(deleted_reply.uuid))
173+
session.commit()
174+
175+
176+
def find_or_create_user(username, session):
177+
"""
178+
Returns a user object representing the referenced username. If the username
179+
does not already exist in the data, a new instance is created.
180+
"""
181+
user = session.query(User).filter_by(username=username)
182+
if user:
183+
return user[0]
184+
new_user = User(username)
185+
session.add(new_user)
186+
session.commit()
187+
return new_user

tests/test_models.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,25 @@ def test_string_representation_of_user():
77

88

99
def test_string_representation_of_source():
10-
source = Source(journalist_designation="testy test", uuid="test")
10+
source = Source(journalist_designation="testy test", uuid="test",
11+
is_flagged=False, public_key='test', interaction_count=1,
12+
is_starred=False, last_updated='test')
1113
source.__repr__()
1214

1315

1416
def test_string_representation_of_submission():
15-
source = Source(journalist_designation="testy test", uuid="test")
17+
source = Source(journalist_designation="testy test", uuid="test",
18+
is_flagged=False, public_key='test', interaction_count=1,
19+
is_starred=False, last_updated='test')
1620
submission = Submission(source=source, uuid="test", filename="test.docx")
1721
submission.__repr__()
1822

1923

2024
def test_string_representation_of_reply():
2125
user = User('hehe')
22-
source = Source(journalist_designation="testy test", uuid="test")
26+
source = Source(journalist_designation="testy test", uuid="test",
27+
is_flagged=False, public_key='test', interaction_count=1,
28+
is_starred=False, last_updated='test')
2329
reply = Reply(source=source, journalist=user, filename="reply.gpg",
24-
size=1234)
30+
size=1234, uuid='test')
2531
reply.__repr__()

0 commit comments

Comments
 (0)