# -*- coding: utf-8 -*-
#
# Copyright (C) 2014-2021 Bitergia
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Authors:
# Miguel Ángel Fernández <mafesan@bitergia.com>
#
import json
import logging
import re
import uuid
import django.core.exceptions
import django.db.utils
from django.contrib.auth.models import User, AnonymousUser
from grimoirelab_toolkit.datetime import datetime_utcnow
from .context import SortingHatContext
from .errors import AlreadyExistsError, ClosedTransactionError
from .models import (Operation,
Transaction)
from .utils import validate_field
logger = logging.getLogger(__name__)
[docs]class TransactionsLog:
"""Class for logging transactions and operations related with the database.
Every object of this class is created using the `open` class method receiving
as a parameter the name of the method opening the transaction, which creates a
`Transaction` object in the database. If the context was created by a job,
the logger will add the job identifier to the name of the transaction.
The method `log_operation` creates a new `Operation` objects linked to the
transaction which was generated when the object was instanced. This method
receives, among other parameters, the arguments from the method logging the
operation in a Python dict object which will be converted to a serialized
JSON.
The `close` method adds a `closed_at` timestamp (before calling this method,
this field has a `NULL` value in the DB) and it also sets to `True` the
`is_closed` flag.
:param trx: Transaction object generated with the class method `open`
:raises ClosedTransactionError: When trying to log an operation on a closed transaction
:raises TypeError: When the `op_type` is not an instance of `Operation.OpType` class
"""
def __init__(self, trx, ctx):
self.trx = trx
self.ctx = ctx
[docs] @classmethod
def open(cls, name, ctx):
"""Create a new transaction object and save it into the DB.
When the context was created by a job, this method will add
the job identifier as a suffix to the name of the transaction.
:param name: mame of the method opening the transaction
:param ctx: context from the method opening the transaction
:returns: a new `TransactionsLog` object containing the
generated `Transaction` object
"""
# Check if input values are valid
validate_field('name', name)
if not isinstance(ctx, SortingHatContext):
msg = "ctx value must be a SortingHatContext; {} given".format(ctx.__class__.__name__)
raise TypeError(msg)
if not isinstance(ctx.user, (User, AnonymousUser)):
msg = "ctx.user must be a Django User or AnonymousUser; {} given".format(ctx.__class__.__name__)
raise TypeError(msg)
trx_name = name
if ctx.job_id:
trx_name += '-' + str(ctx.job_id)
tuid = uuid.uuid4().hex
username = None
if not isinstance(ctx.user, AnonymousUser):
username = ctx.user.username
validate_field('username', username)
trx = Transaction(tuid=tuid,
name=trx_name,
created_at=datetime_utcnow(),
authored_by=username)
try:
trx.save(force_insert=True)
except django.db.utils.IntegrityError as exc:
_handle_integrity_error(Transaction, exc, tuid)
logger.debug(
f"Transaction {trx.tuid} started; "
f"name='{trx.name}' author='{trx.authored_by}'"
)
return cls(trx, ctx)
[docs] def close(self):
"""Close a given transaction adding a timestamp as closing date and setting a flag"""
self.trx.closed_at = datetime_utcnow()
self.trx.is_closed = True
try:
self.trx.save()
except django.db.utils.IntegrityError as exc:
_handle_integrity_error(Transaction, exc, self.trx.tuid)
logger.debug(f"Transaction {self.trx.tuid} finished")
[docs] def log_operation(self, op_type, entity_type, timestamp, args, target):
"""Create a new operation object and save it into the DB.
:param op_type: Type of the operation which is recorded (ADD, DELETE, UPDATE)
:param entity_type: Type of entity involved in the operations (UUID, ENROLLMENT, etc.)
:param timestamp: Datetime when the operation is created
:param args: Input arguments from the method creating the operation
:param target: Argument which the operation is directed to
:raises ClosedTransactionError: When trying to log an operation on a closed transaction
:raises TypeError: When the `op_type` is not an instance of `Operation.OpType` class
:returns: a new Operation object
"""
if self.trx.is_closed:
msg = 'Log operation not allowed, transaction {} is already closed'.format(self.trx.tuid)
raise ClosedTransactionError(msg=msg)
# Check if input values are valid
validate_field('entity_type', entity_type)
validate_field('target', target)
if not isinstance(op_type, Operation.OpType):
msg = "'op_type' value must be a 'Operation.OpType'; {} given".format(op_type.__class__.__name__)
raise TypeError(msg)
args_dump = json.dumps(args)
ouid = uuid.uuid4().hex
operation = Operation(ouid=ouid, trx=self.trx, op_type=op_type, target=target,
entity_type=entity_type, timestamp=timestamp, args=args_dump)
try:
operation.save(force_insert=True)
except django.db.utils.IntegrityError as exc:
_handle_integrity_error(Operation, exc, self.trx.tuid)
logger.debug(
f"Operation {operation.ouid} completed; "
f"trx='{operation.trx.tuid}' op='{operation.op_type}' "
f"type='{entity_type}' target='{target}' args={args};"
)
return operation
_MYSQL_DUPLICATE_ENTRY_ERROR_REGEX = re.compile(r"Duplicate entry '(?P<value>.+)' for key")
def _handle_integrity_error(model, exc, tuid):
"""Handle integrity error internal logging exceptions"""
logger.error(f"Transaction {tuid} aborted; integrity error;",
exc_info=True)
m = re.match(_MYSQL_DUPLICATE_ENTRY_ERROR_REGEX,
exc.__cause__.args[1])
if not m:
raise exc
entity = model.__name__
eid = m.group('value')
raise AlreadyExistsError(entity=entity, eid=eid)