def load_stream(self, session, aggregate_uuid): """Query an event stream from an aggregate. Args: session (Session): A session context into which operate. aggregate_uuid (str): An UUID4 string. The aggregate from which query the events. Returns: Query: A Query with rows from the "events" table. """ try: aggregate = session.query(AggregateModel).options( joinedload('events')).filter( AggregateModel.uuid == str(aggregate_uuid)).one() except NoResultFound: raise NotFoundError(f'Aggregate not found: {aggregate_uuid}') version = aggregate.version events = aggregate.events event_stream = EventStream(version, events) logger.debug(event_stream) return event_stream
def translations(): """Alternative route to get only tranlation JSON. This route is used by the frontend to poll translations from a single page and dynamically update the translations table. Args: page (int): There is a limit of translations displayed per page, and a page can be selected passing this URL argument. """ page = request.args.get('page', 1, type=int) pagination = projections.get(page) logger.debug(f'processing GET "/json", page:{page}/{pagination.last_page}') translations_json = jsonify([{ 'text': translation.text or '', 'status': translation.status or '', 'translated_text': translation.translated_text or '' } for translation in pagination.items]) return translations_json
def nmt_process(self, translation): """Automatically process translation and update its status. Args: translation (Translation): The translation to be processed. """ text = translation.text logger.debug(f'Requesting automatic translation') try: translated_text = self._marian_client.request_translation(text) except: # noqa: E722 event = TranslationAborted.create( f'Marian-NMT server request failed.') translation.apply(event) logger.error(f'Marian-NMT server request failed.') return translation event = TranslationFinished.create(translated_text) translation.apply(event) return translation
def index(): """Application main route. Responding to a GET request, it renders a single-page application with a form to input an English text to be translated and a table containing a list of previous translations ordered by the length or the translated text. Responding to a POST request it validates if the form (avoiding a blank input), creates a new Translation aggregate with the given input and then saves it to the repository to be processesd in the background (and re-processed if the application breaks for some reason before enqueuing the task). Args: page (int): There is a limit of translations displayed per page, and a page can be selected passing this URL argument. """ form = TranslationForm() if form.validate_on_submit(): # POST handling logger.debug(f'processing POST "/"') text = form.text.data text = text.replace("'", "\\'") text = text.replace('\r', '') text = text.replace('\n', '') translation = Translation.create(text) repository.save(translation) method = form.method.data if method == 'mt': tasks.nmt_task.send(translation.id) else: tasks.translation_task.send(translation.id) tasks.projections_task.send(translation.id) return redirect(url_for('index')) # GET handling page = request.args.get('page', 1, type=int) translations = projections.get(page) logger.debug( f'processing GET "/json", page:{page}/{translations.last_page}') next_url = url_for('index', page=translations.next_page) \ if translations.has_next else None prev_url = url_for('index', page=translations.prev_page) \ if translations.has_prev else None return render_template('index.html', form=form, translations=translations.items, next_url=next_url, prev_url=prev_url)
def insert_or_update(self, session, aggregate_uuid, status, original_text, translated_text=None): """Insert or update an aggregate in the "translations" table. Args: session (Session): A session context into which operate. aggregate_uuid (str): An UUID4 string. It must match its respective event sourced aggregate. text (str): The text sent to the translation service. status (str): The translation processing status. translated_text (str): The text received from the translation service. """ if status == 'requested': sql = text( f'INSERT INTO translations (uuid, status, length, text) ' f'VALUES (:uuid, :status, :length, :text) ON CONFLICT DO NOTHING' ) values = { 'uuid': aggregate_uuid, 'status': 'requested', 'length': 0, 'text': original_text } elif status == 'pending': sql = text(f"UPDATE translations " f"SET status = 'pending' " f"WHERE uuid = :aggregate_uuid") values = {'aggregate_uuid': aggregate_uuid} elif status == 'finished': sql = text(f"UPDATE translations " f"SET status = 'finished', " f"length = {len(translated_text)}, " f"translated_text = :translated_text " f"WHERE uuid = :aggregate_uuid") values = { 'aggregate_uuid': aggregate_uuid, 'translated_text': translated_text } elif status == 'aborted': sql = text(f"UPDATE translations " f"SET status = 'aborted' " f"WHERE uuid = :aggregate_uuid") values = {'aggregate_uuid': aggregate_uuid} logger.debug(sql) session.execute(sql, values)
def trigger(self, event): """Trigger an event action to the aggregate. It uses the `singledispatch` decorator to decide which action to take based on the translation Event class input. Args: event (Event): An event. """ @singledispatch def _trigger(_event): raise InvalidEventError(f'Invalid event: {_event}') @_trigger.register(TranslationRequested) def _(_event): if self.status != '': raise InvalidStatusError( f'Invalid status transition: {self.status}->requested') self.text = _event.text self.status = 'requested' @_trigger.register(TranslationPending) def _(_event): if self.status != 'requested': raise InvalidStatusError( f'Invalid status transition: {self.status}->pending') self.translation_id = _event.translation_id self.status = 'pending' @_trigger.register(TranslationFinished) def _(_event): if self.status != 'pending': raise InvalidStatusError( f'Invalid status transition: {self.status}->finished') self.translated_text = _event.translated_text self.status = 'finished' self.finished = True @_trigger.register(TranslationAborted) def _(_event): self.error = _event.error self.status = 'aborted' self.finished = True logger.info(f'applying event ({event.id}) on aggregate: {self.id}') logger.debug(event.as_dict()) _trigger(event)
def callback(id=None): """Callback route to update a Translation resource. This route is used by an external translation service to notify it finished processing a text. Args: id (str): The Translation aggregate ID to update it. """ if id: logger.debug(f'processing POST "/callback/{id}"') translation = repository.get(id) translation = translator.get(translation) repository.save(translation) tasks.projections_task.send(id) return jsonify(success=True) return jsonify(error=404, text="Resource not found.")
def get(self, translation): """Get a requested translation (and update it if required). If the request resource status is different from the local Translation's, the local translation resource is updated accordingly. Args: translation (Translation): The translation to be get (and updated). """ logger.debug(f'Requesting translation {translation.id}:' f'{translation.translation_id}') response = self._unbabel_client.get_translation( translation.translation_id) # Check for a response if response: uid = response['uid'] status = response['status'] # Check if status changed if (translation.status == status): return translation logger.debug(f'Updating translation {translation.id}:' f'{translation.translation_id}') # Check wheter update a finished translation if status in _translation_finished: event = TranslationFinished.create(response['translatedText']) translation.apply(event) # Check wheter update a pending translation elif status in _translation_pending: event = TranslationPending.create(uid) translation.apply(event) # Check any other status unknown else: event = TranslationAborted.create( f'Translation error: {status}') translation.apply(event) else: # Missing response logger.debug(f"Client returned an error: not found") event = TranslationAborted.create(f'Translation not found.') translation.apply(event) return translation
def process(self, translation): """Process a requested translation and update its status. As its through an external service, it changes the Translation resource status to "pending" after snding a request to the Unbabel's API. Args: translation (Translation): The translation to be processed. """ text = translation.text callback_url = urljoin(settings.API_CALLBACK, translation.id) logger.debug( f"Requesting translation with callback to '{callback_url}'") response = self._client.request_translation(text, self._source_language, self._target_language, callback_url) # Check for request errors if 'error' in response.keys(): event = TranslationAborted.create( f"Request error: {response['error']}") translation.apply(event) logger.debug(f"Client returned an error: {response['error']}") return translation uid = response['uid'] status = response['status'] logger.debug(f'Updating translation {translation.id}:{uid}') if status in _translation_pending: event = TranslationPending.create(uid) translation.apply(event) else: event = TranslationAborted.create(f'Translation error: {status}') translation.apply(event) return translation
def append_to_stream(self, session, aggregate_uuid, expected_version, events): """Append an event stream from an aggregate. Args: session (Session): A session context into which operate. aggregate_uuid (str): An UUID4 string. The aggregate from which query the events. expected_version (int): Version for optimistic lock. Before appending an event to an event stream, its aggregate must be loaded to avoid inconsistencies. Then, the `expected_version` is set and verified to make sure no other session will be able to alter the same aggregate at the same time. events ([Event]): List of events to append to the event stream. They will be converted to a dict formatted as a JSON before the operation. """ if expected_version: # If an `expected_version` is given, the aggregate must be # updated as it is already in some version. sql = text(f"UPDATE aggregates " f"SET version = :expected_version + 1 " f"WHERE version = :expected_version " f"AND uuid = :aggregate_uuid") values = { 'expected_version': expected_version, 'aggregate_uuid': aggregate_uuid } logger.debug(sql, values) result = session.execute(sql, values) if result.rowcount != 1: raise ConcurrencyError( 'Failed to update aggregate in database.') else: # Or else it's a new aggregate. sql = text(f"INSERT INTO aggregates (uuid, version) " f"VALUES (:aggregate_uuid, 1)") values = {'aggregate_uuid': aggregate_uuid} logger.debug(sql) result = session.execute(sql, values) if result.rowcount != 1: raise WriteError('Failed to insert aggregate into database.') for event in events: # Iterate through events and trying to add them to "events" table, # relating each and every one with the same aggregate. But, in the # occasion an event is already there (based on its UUID column), # it's dropped (`DO NOTHING`). sql = text( f"INSERT INTO events (uuid, aggregate_uuid, event, data) " f"VALUES (:uuid,:aggregate_uuid,:event,:data) ON CONFLICT (uuid) DO NOTHING" ) values = { 'uuid': event.id, 'aggregate_uuid': aggregate_uuid, 'event': event.__class__.__name__, 'data': json.dumps(event.as_dict()) } result = session.execute(sql, values) logger.debug(sql) if result.rowcount: logger.info(f'new event: {event.id}') else: logger.debug(f'no new event')