Ejemplo n.º 1
0
def run(scheduled_process: ScheduledProcess) -> None:
    """ Checks whether a new backup should be created. Creates one if needed as well. """

    # Create a partial, minimal backup first. Since it will grow and take disk space, only create one weekly.
    today = timezone.localtime(timezone.now()).date()

    if today.isoweekday() == 1:
        create_partial(folder=os.path.join(get_backup_directory(), 'archive',
                                           formats.date_format(today, 'Y'),
                                           formats.date_format(today, 'm')),
                       models_to_backup=(DayStatistics, ))

    # Now create full.
    create_full(folder=get_backup_directory())

    # Schedule tomorrow, for the time specified.
    backup_settings = BackupSettings.get_solo()
    next_backup_timestamp = timezone.now() + timezone.timedelta(days=1)
    next_backup_timestamp = timezone.localtime(next_backup_timestamp)

    next_backup_timestamp = next_backup_timestamp.replace(
        hour=backup_settings.backup_time.hour,
        minute=backup_settings.backup_time.minute,
        second=0,
        microsecond=0)

    scheduled_process.reschedule(next_backup_timestamp)
Ejemplo n.º 2
0
def get_dropbox_client(scheduled_process: ScheduledProcess) -> dropbox.Dropbox:
    """
    The Dropbox refresh token we locally store (after linking the user's Dropbox account) never expires. This method
    returns a new and authenticated instance of the Dropbox client that can be used for its (short) duration.
    """
    dropbox_settings = DropboxSettings.get_solo()
    dbx = dropbox.Dropbox(oauth2_refresh_token=dropbox_settings.refresh_token,
                          app_key=settings.DSMRREADER_DROPBOX_APP_KEY)

    try:
        dbx.refresh_access_token()
        dbx.check_user()  # No-op, just to verify the client/session.
    except Exception as error:
        logger.error(' - Dropbox error: %s', error)

        # Network errors should NOT reset the client side app token (see further below). Only API errors should do so.
        if not isinstance(error, dropbox.exceptions.DropboxException):
            scheduled_process.delay(minutes=1)
            raise

        logger.error(' - Removing Dropbox credentials due to API failure')
        message = _(
            "Unable to authenticate with Dropbox, removing credentials. Error: {}"
            .format(error))
        dsmr_frontend.services.display_dashboard_message(message=message)
        DropboxSettings.objects.update(refresh_token=None,
                                       )  # Does not trigger auto disable
        scheduled_process.disable()
        raise

    logger.info('Dropbox: Auth/user check OK')
    return dbx
Ejemplo n.º 3
0
def run(scheduled_process: ScheduledProcess) -> None:
    """ Compacts all unprocessed readings, capped by a max to prevent hanging backend. """
    for current_reading in DsmrReading.objects.unprocessed(
    )[0:settings.DSMRREADER_COMPACT_MAX]:
        try:
            compact(dsmr_reading=current_reading)
        except CompactorNotReadyError:
            # Try again in a while, since we can't do anything now anyway.
            scheduled_process.delay(seconds=15)
            return

    scheduled_process.delay(seconds=1)
Ejemplo n.º 4
0
def sync_file(scheduled_process: ScheduledProcess,
              dropbox_client: dropbox.Dropbox, local_root_dir: str,
              abs_file_path: str) -> None:
    # The path we use in our Dropbox app folder.
    relative_file_path = abs_file_path.replace(local_root_dir, '')

    try:
        # Check whether the file is already at Dropbox, if so, check its hash.
        dropbox_meta = dropbox_client.files_get_metadata(relative_file_path)
    except dropbox.exceptions.ApiError as exception:
        error_message = str(exception.error)
        dropbox_meta = None

        # Unexpected.
        if 'not_found' not in error_message:
            logger.error(' - Dropbox error: %s', error_message)
            return

    # Calculate local hash and compare with remote. Ignore if the remote file is exactly the same.
    if dropbox_meta and calculate_content_hash(
            abs_file_path) == dropbox_meta.content_hash:
        logger.debug('Dropbox: Content hash is the same, skipping: %s',
                     relative_file_path)
        return

    try:
        upload_chunked(dropbox_client=dropbox_client,
                       local_file_path=abs_file_path,
                       remote_file_path=relative_file_path)
    except dropbox.exceptions.DropboxException as exception:
        error_message = str(exception.error)
        logger.error('Dropbox: %s', error_message)

        if 'insufficient_space' in error_message:
            message = _(
                "Unable to upload files to Dropbox due to {}. Ignoring new files for the next {} hours..."
                .format(error_message,
                        settings.DSMRREADER_DROPBOX_ERROR_INTERVAL))
            dsmr_frontend.services.display_dashboard_message(message=message)
            scheduled_process.delay(
                hours=settings.DSMRREADER_DROPBOX_ERROR_INTERVAL)

        raise  # pragma: no cover
Ejemplo n.º 5
0
def run(scheduled_process: ScheduledProcess) -> None:
    """ Checks for new updates. If one is available, it's displayed on the Dashboard. """
    try:
        is_latest_version = dsmr_backend.services.backend.is_latest_version()
    except Exception as error:
        logger.error('Update checker: Error %s', error)
        scheduled_process.delay(hours=1)
        return

    if not is_latest_version:
        logger.debug('Update checker: Newer version of DSMR-reader available')
        dsmr_frontend.services.display_dashboard_message(
            message=_(
                'There is a newer version of DSMR-reader available. See the changelog for more information.'
            ),
            redirect_to='frontend:changelog-redirect'
        )

    scheduled_process.delay(days=7)
Ejemplo n.º 6
0
def run(scheduled_process: ScheduledProcess) -> None:
    dropbox_settings = DropboxSettings.get_solo()

    if not dropbox_settings.refresh_token:
        # Should not happen, safe fallback
        scheduled_process.disable()
        return

    dropbox_client = get_dropbox_client(scheduled_process=scheduled_process)
    backup_directory = dsmr_backup.services.backup.get_backup_directory()

    # Sync each file, recursively.
    for current_file in list_files_in_dir(directory=backup_directory):
        if not should_sync_file(current_file):
            continue

        sync_file(scheduled_process=scheduled_process,
                  dropbox_client=dropbox_client,
                  local_root_dir=backup_directory,
                  abs_file_path=current_file)

    scheduled_process.delay(hours=settings.DSMRREADER_DROPBOX_SYNC_INTERVAL)
Ejemplo n.º 7
0
def run(scheduled_process: ScheduledProcess) -> None:
    mindergas_settings = MinderGasSettings.get_solo()

    # Only when enabled and token set.
    if not mindergas_settings.auth_token:
        mindergas_settings.update(export=False)  # Should also disable SP.
        return

    # Nonsense when having no data.
    if not dsmr_backend.services.backend.get_capability(Capability.GAS):
        scheduled_process.delay(hours=1)
        return

    try:
        export()
    except Exception as error:
        logger.exception(error)

        scheduled_process.delay(hours=1)
        dsmr_frontend.services.display_dashboard_message(message=_(
            'Failed to export to MinderGas: {}'.format(error)
        ))
        return

    # Reschedule between 3 AM and 6 AM next day.
    midnight = timezone.localtime(timezone.make_aware(
        timezone.datetime.combine(timezone.now(), time.min)
    ))
    next_midnight = midnight + timezone.timedelta(
        hours=dsmr_backend.services.backend.hours_in_day(
            day=timezone.now().date()
        )
    )
    scheduled_process.reschedule(
        next_midnight + timezone.timedelta(
            hours=random.randint(3, 5),
            minutes=random.randint(15, 59)
        )
    )
Ejemplo n.º 8
0
def run(scheduled_process: ScheduledProcess) -> None:
    retention_settings = RetentionSettings.get_solo()

    if retention_settings.data_retention_in_hours == RetentionSettings.RETENTION_NONE:
        scheduled_process.disable()  # Changing the retention settings in the admin will re-activate it again.
        return

    # These models should be rotated with retention. Dict value is the datetime field used.
    ITEM_COUNT_PER_HOUR = 2
    MODELS_TO_CLEANUP = {
        DsmrReading.objects.processed(): 'timestamp',
        ElectricityConsumption.objects.all(): 'read_at',
        GasConsumption.objects.all(): 'read_at',
    }

    retention_date = timezone.now() - timezone.timedelta(hours=retention_settings.data_retention_in_hours)
    data_to_clean_up = False

    # We need to force UTC here, to avoid AmbiguousTimeError's on DST changes.
    timezone.activate(pytz.UTC)

    for base_queryset, datetime_field in MODELS_TO_CLEANUP.items():
        hours_to_cleanup = base_queryset.filter(
            **{'{}__lt'.format(datetime_field): retention_date}
        ).annotate(
            item_hour=TruncHour(datetime_field)
        ).values('item_hour').annotate(
            item_count=Count('id')
        ).order_by().filter(
            item_count__gt=ITEM_COUNT_PER_HOUR
        ).order_by('item_hour').values_list(
            'item_hour', flat=True
        )[:settings.DSMRREADER_RETENTION_MAX_CLEANUP_HOURS_PER_RUN]

        hours_to_cleanup = list(hours_to_cleanup)  # Force evaluation.

        if not hours_to_cleanup:
            continue

        data_to_clean_up = True

        for current_hour in hours_to_cleanup:
            # Fetch all data per hour.
            data_set = base_queryset.filter(
                **{
                    '{}__gte'.format(datetime_field): current_hour,
                    '{}__lt'.format(datetime_field): current_hour + timezone.timedelta(hours=1),
                }
            )

            # Extract the first/last item, so we can exclude it.
            # NOTE: Want to alter this? Please update ITEM_COUNT_PER_HOUR above as well!
            keeper_pks = [
                data_set.order_by(datetime_field)[0].pk,
                data_set.order_by('-{}'.format(datetime_field))[0].pk
            ]

            # Now drop all others.
            logger.debug('Retention: Cleaning up: %s (%s)', current_hour, data_set[0].__class__.__name__)
            data_set.exclude(pk__in=keeper_pks).delete()

    timezone.deactivate()

    # Delay for a bit, as there is nothing to do.
    if not data_to_clean_up:
        scheduled_process.delay(hours=12)
Ejemplo n.º 9
0
def run_quarter_hour_peaks(scheduled_process: ScheduledProcess) -> None:
    """ Calculates the quarter-hour peak consumption. For background info see issue #1084 ."""
    MINUTE_INTERVAL = 15

    # Just start with whatever time this process was scheduled.
    # As it's incremental and will fix data gaps (see further below).
    fuzzy_start = scheduled_process.planned.replace(second=0, microsecond=0)

    # The fuzzy start should be just beyond whatever we target. E.g. fuzzy start = currently 14:34
    logger.debug('Quarter hour peaks: Using %s as fuzzy start',
                 timezone.localtime(fuzzy_start))

    # Rewind at least 15 minutes. E.g. currently 14:34 -> 14:19 (rewind_minutes = 15)
    rewind_minutes = MINUTE_INTERVAL

    # Map to xx:00, xx:15, xx:30 or xx:45. E.g. 14:19 -> 14:15. Makes 19 % 15 = 4 (rewind_minutes = 15 + 4)
    rewind_minutes += (fuzzy_start - timezone.timedelta(minutes=rewind_minutes)
                       ).minute % MINUTE_INTERVAL

    # E.g. Fuzzy start was 14:34. Now we start/end at 14:15/14:30.
    start = fuzzy_start - timezone.timedelta(minutes=rewind_minutes)
    end = start + timezone.timedelta(minutes=MINUTE_INTERVAL)

    # Do NOT continue until we've received new readings AFTER the targeted end. Ensuring we do not miss any and it also
    # blocks the "self-healing" implementation when having data gaps.
    # Only happens for data gaps or directly after new installations (edge cases). This will keep pushing forward.
    if not DsmrReading.objects.filter(timestamp__gte=end).exists():
        logger.debug(
            'Quarter hour peaks: Ready but awaiting any new readings after %s, postponing for a bit...',
            timezone.localtime(end),
        )

        # Assumes new readings will arrive shortly (for most users/setups)
        scheduled_process.postpone(seconds=5)
        return

    quarter_hour_readings = DsmrReading.objects.filter(timestamp__gte=start,
                                                       timestamp__lte=end)

    # Only happens for data gaps or directly after new installations (edge cases). This will keep pushing forward.
    if len(quarter_hour_readings) < 2:
        logger.warning(
            'Quarter hour peaks: Ready but not enough readings found between %s - %s, skipping quarter...',
            timezone.localtime(start),
            timezone.localtime(end),
        )
        scheduled_process.postpone(minutes=MINUTE_INTERVAL)
        return

    first_reading = quarter_hour_readings.first()
    last_reading = quarter_hour_readings.last()
    logger.debug(
        'Quarter hour peaks: Quarter %s - %s resulted in readings %s - %s',
        timezone.localtime(start),
        timezone.localtime(end),
        timezone.localtime(first_reading.timestamp),
        timezone.localtime(last_reading.timestamp),
    )

    # Do not create duplicate data.
    existing_data = QuarterHourPeakElectricityConsumption.objects.filter(
        read_at_start__gte=start,
        read_at_start__lte=end,
    ).exists()

    if existing_data:
        logger.debug(
            'Quarter hour peaks: Ready but quarter already processed, rescheduling for next quarter...'
        )
        scheduled_process.reschedule(
            planned_at=end + timezone.timedelta(minutes=MINUTE_INTERVAL))
        return

    # Calculate quarter data.
    total_delivered_start = first_reading.electricity_delivered_1 + first_reading.electricity_delivered_2
    total_delivered_end = last_reading.electricity_delivered_1 + last_reading.electricity_delivered_2
    avg_delivered_in_quarter = total_delivered_end - total_delivered_start
    logger.debug(
        'Quarter hour peaks: Calculating for %s - %s',
        timezone.localtime(first_reading.timestamp),
        timezone.localtime(last_reading.timestamp),
    )

    new_instance = QuarterHourPeakElectricityConsumption.objects.create(
        # Using the reading timestamps used to ensure we can indicate gaps or lag in reading input.
        # E.g. due backend/datalogger process sleep or simply v4 meters emitting a reading only once per 10 seconds.
        read_at_start=first_reading.timestamp,
        read_at_end=last_reading.timestamp,
        # avg_delivered_in_quarter = kW QUARTER peak during 15 minutes... x 4 maps it to avg per hour for kW HOUR peak
        average_delivered=avg_delivered_in_quarter * 4)
    logger.debug('Quarter hour peaks: Created %s', new_instance)

    # Reschedule around the next moment we can expect to process the next quarter. Also works retroactively/with gaps.
    scheduled_process.reschedule(planned_at=new_instance.read_at_end +
                                 timezone.timedelta(minutes=MINUTE_INTERVAL))
Ejemplo n.º 10
0
def run(scheduled_process: ScheduledProcess) -> None:
    """ Analyzes daily consumption and statistics to determine whether new analysis is required. """
    if not is_data_available():
        logger.debug('Stats: No data available')
        scheduled_process.delay(hours=1)
        return

    now = timezone.localtime(timezone.now())
    target_day = get_next_day_to_generate()
    next_day = target_day + timezone.timedelta(days=1)

    # Skip current day, wait until midnight.
    if target_day >= now.date():
        logger.debug('Stats: Waiting for day to pass: %s', target_day)
        scheduled_process.reschedule(
            timezone.make_aware(timezone.datetime.combine(next_day, time.min)))
        return

    # All readings of the day must be processed.
    unprocessed_readings = DsmrReading.objects.unprocessed().filter(
        timestamp__date=target_day).exists()

    if unprocessed_readings:
        logger.debug('Stats: Found unprocessed readings for: %s', target_day)
        scheduled_process.delay(minutes=5)
        return

    # Ensure we have any consumption.
    consumption_found = ElectricityConsumption.objects.filter(
        read_at__date=target_day).exists()

    if not consumption_found:
        logger.debug('Stats: Missing consumption data for: %s', target_day)
        scheduled_process.delay(hours=1)
        return

    # If we recently supported gas, make sure we've received a gas reading on the next day (or later).
    recently_gas_read = GasConsumption.objects.filter(
        read_at__date__gte=target_day - timezone.timedelta(days=1)).exists()

    # Unless it was disabled.
    gas_capability = dsmr_backend.services.backend.get_capability(
        Capability.GAS)

    if gas_capability and recently_gas_read and not GasConsumption.objects.filter(
            read_at__date__gte=next_day).exists():
        logger.debug('Stats: Waiting for first gas reading on the next day...')
        scheduled_process.delay(minutes=5)
        return

    create_statistics(target_day=target_day)

    # We keep trying until we've caught on to the current day (which will then delay it for a day above).
    scheduled_process.delay(seconds=1)
    return