Skip to content

Commit

Permalink
new iteration of services (still some way to go, particularly with de…
Browse files Browse the repository at this point in the history
…p support)
  • Loading branch information
gmega committed Aug 19, 2019
1 parent 0adb242 commit 9d0c175
Show file tree
Hide file tree
Showing 18 changed files with 349 additions and 149 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ Para rodar o bloqueador, você vai precisar de:
* **Um conversor de DTMF para FSK (talvez).** Também disponível no Mercado Livre a preços que vão de 30 a 150 reais.
Eu tenho uma linha fixa tradicional da [Vivo](https://www.vivo.com.br) (i.e., não é aquela linha que brota do Vivo
fibra) e, nessas linhas, a Vivo utiliza modulação DTMF para a identificação de chamadas. A
[Wikipedia](https://en.wikipedia.org/wiki/Caller_ID) no entanto aponta que outras operadoras podem usar outros
padrões (FSK e V23 FSK), então o conversor pode não ser necessário no seu caso.
[Wikipedia](https://en.wikipedia.org/wiki/Caller_ID) no entanto aponta que outras operadoras **no Brasil**
podem usar outros padrões (FSK e V23 FSK), então o conversor pode não ser necessário no seu caso.

Depois de montado, o hardware do meu bloqueador ficou com essa cara aqui:

Expand Down Expand Up @@ -136,6 +136,9 @@ _Voilà!_ O bloqueador de chamadas está rodando! Mas isso não quer dizer, clar
forma de se assegurar que o bloqueador está de fato funcionando, por hora, é fazer uma ligação para o próprio número. Se
tudo estiver funcionando, ela deve aparecer no lugar do aviso de que não há chamadas para mostrar.

Note que o Docker vai se encarregar de inicializar o bloqueador novamente toda vez que o Raspberry Pi for reiniciado,
então você não precisa se preocupar com criar scripts de inicialização (se é que estava preocupado ;-)).

Limitações
==========

Expand Down
1 change: 1 addition & 0 deletions callblocker/blocker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class BootstrapMode(Enum):
SERVER = 'server'
FAKE_SERVER = 'fake_server'
COMMAND = 'command'
CUSTOM = 'custom'


#: This is an ugly hack. See the blocker.bootstrap module documentation for more information.
Expand Down
40 changes: 39 additions & 1 deletion callblocker/blocker/api/serializer_extensions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from functools import reduce

from rest_framework.exceptions import ValidationError
from rest_framework.fields import SkipField, Field
from rest_framework.fields import SkipField, Field, ChoiceField
from rest_framework.serializers import Serializer
from rest_framework.settings import api_settings
from rest_framework.utils import html
from rest_framework_bulk import BulkListSerializer
Expand All @@ -10,6 +11,7 @@
# We have to patch BulkListSerializer to deal with https://github.com/miki725/django-rest-framework-bulk/issues/68
# This may break with newer versions of restframework, so it would be nice to get rid of it, eventually.


class PatchedBulkListSerializer(BulkListSerializer):
def to_internal_value(self, data):
"""
Expand Down Expand Up @@ -59,6 +61,22 @@ def to_internal_value(self, data):
return ret


class EnumField(ChoiceField):
def __init__(self, enum, **kwargs):
self.enum = enum
kwargs['choices'] = [(e.value, e.name) for e in enum]
super(EnumField, self).__init__(**kwargs)

def to_representation(self, obj):
return obj.name

def to_internal_value(self, data):
try:
return self.enum[data]
except KeyError:
self.fail('invalid_choice', input=data)


class GeneratedCharField(Field):

def __init__(self, fields, fun=lambda x, y: x + y, **kwargs):
Expand Down Expand Up @@ -87,3 +105,23 @@ def to_internal_value(self, data):

def to_representation(self, value):
return value


class ExceptionField(Field):
def __init__(self, **kwargs):
# Our typical use case for Exceptions is reporting. Clients are not usually
# allowed to set them.
kwargs['read_only'] = True
super().__init__(**kwargs)

def to_representation(self, value):
# Well... can't get any simpler than this.
return f'{type(value).__name__}: {str(value)}'


class ROSerializer(Serializer):
def create(self, validated_data):
raise NotImplemented('Cannot create readonly objects.')

def update(self, instance, validated_data):
raise NotImplemented('Cannot update readonly objects.')
16 changes: 15 additions & 1 deletion callblocker/blocker/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from rest_framework.validators import UniqueValidator
from rest_framework_bulk import BulkSerializerMixin

from callblocker.blocker.api.serializer_extensions import GeneratedCharField, PatchedBulkListSerializer
from callblocker.blocker.api.serializer_extensions import GeneratedCharField, PatchedBulkListSerializer, EnumField, \
ExceptionField, ROSerializer
from callblocker.blocker.models import Call, Source, Caller
from callblocker.core.service import ServiceState


class CallSerializer(ModelSerializer):
Expand Down Expand Up @@ -101,3 +103,15 @@ def create(self, validated_data):
if validated_data.get('source') is None:
validated_data['source'] = Source.predef_source(Source.USER)
return super().create(validated_data)


class ServiceStatusSerializer(ROSerializer):
state = EnumField(ServiceState)
exception = ExceptionField()
traceback = serializers.CharField()


class ServiceSerializer(ROSerializer):
id = serializers.CharField(max_length=20)
name = serializers.CharField(max_length=80)
status = ServiceStatusSerializer()
64 changes: 57 additions & 7 deletions callblocker/blocker/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,24 @@
from django.db.models import Count, Value, FloatField
from django.db.models import Q
from django.db.models.functions import Greatest, Lower
from django.http import Http404
from rest_framework import status
from rest_framework.decorators import api_view, parser_classes
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import ValidationError, APIException
from rest_framework.generics import get_object_or_404
from rest_framework.mixins import RetrieveModelMixin, ListModelMixin
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_202_ACCEPTED
from rest_framework.viewsets import ModelViewSet, GenericViewSet
from rest_framework.viewsets import ModelViewSet, GenericViewSet, ViewSet
from rest_framework_bulk import BulkUpdateModelMixin, BulkDestroyModelMixin

from callblocker.blocker.services import services
from callblocker.blocker.api.serializers import CallerSerializer, CallSerializer, CallerPOSTSerializer, SourceSerializer
from callblocker.blocker.api.serializers import CallerSerializer, CallSerializer, CallerPOSTSerializer, \
SourceSerializer, ServiceSerializer
from callblocker.blocker.models import Caller, Call, Source
from callblocker.blocker.services import services
from callblocker.core.service import ServiceState


class CallerViewSet(ModelViewSet, BulkUpdateModelMixin, BulkDestroyModelMixin):
Expand Down Expand Up @@ -144,9 +148,55 @@ class SourceViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
queryset = Source.objects.all()


@api_view(['GET'])
def health_status(request):
return Response(services().health())
class ServicesViewset(ViewSet):
serializer_class = ServiceSerializer
parser_classes = [JSONParser]

# The user can either start or terminate a service. Nothing else.
ALLOWED_TARGET_STATES = [ServiceState.READY, ServiceState.TERMINATED]

def list(self, _):
return Response(ServiceSerializer(instance=services().services, many=True).data)

def retrieve(self, _, pk):
service = self._get_service_or_404(pk)
return Response(ServiceSerializer(instance=service).data)

def partial_update(self, request, pk):
service = self._get_service_or_404(pk)
content = request.data

try:
target = self._get_or_400(content, 'status', 'state')
target = ServiceState[target.upper()]
except KeyError:
return Response(f'Invalid target state {target}.', status=status.HTTP_400_BAD_REQUEST)

if target not in self.ALLOWED_TARGET_STATES:
return Response(f'Cannot set service to {target}.')

# Either start or stop.
if target == ServiceState.READY:
service.start()
elif target == ServiceState.TERMINATED:
service.stop()
else:
# Should never happen.
raise Exception(f'Bad target state {target}.')

return Response(status=status.HTTP_202_ACCEPTED)

def _get_or_400(self, content, *path):
element = path[0]
if element not in content:
raise APIException(detail=f'Missing element {element} in {str(content)}')
return self._get_or_400(content[element], *path[1:]) if len(path) > 1 else content[element]

def _get_service_or_404(self, pk):
service = getattr(services(), pk, None)
if service is None:
raise Http404(f'No services match {pk}.')
return service


@api_view(['POST'])
Expand Down
4 changes: 2 additions & 2 deletions callblocker/blocker/callmonitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def parse_cid(self, string: str) -> Caller:


class CallMonitor(AsyncioService):
default_name = 'call monitor'
name = 'call monitor'

def __init__(self, provider: TelcoProvider, modem: Modem, aio_loop: AbstractEventLoop):
super().__init__(aio_loop=aio_loop)
Expand All @@ -41,7 +41,7 @@ def __init__(self, provider: TelcoProvider, modem: Modem, aio_loop: AbstractEven
self.stream = modem.event_stream()

async def _event_loop(self):
self._startup_event.set()
self._signal_started()
with self.stream as stream:
async for event in stream:
await self._process_event(event)
Expand Down
2 changes: 1 addition & 1 deletion callblocker/blocker/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
from callblocker.core.modem import Modem, PySerialDevice
from callblocker.core.service import AsyncioEventLoop
from callblocker.core.servicegroup import ServiceGroupSpec, ServiceGroup
#: Server mode services.
from callblocker.core.tests.fakeserial import CX930xx_fake, ScriptedModem

#: Server mode services.
server = ServiceGroupSpec(
aio_loop=lambda _: (
AsyncioEventLoop()
Expand Down
File renamed without changes.
94 changes: 94 additions & 0 deletions callblocker/blocker/tests/test_service_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import json

from rest_framework import status

import callblocker
from callblocker.blocker import services, BootstrapMode
from callblocker.blocker.services import bootstrap
from callblocker.core.service import Service, ServiceStatus, ServiceState
from callblocker.core.servicegroup import ServiceGroupSpec


class FlippinService(Service):
def __init__(self, name):
self.state = ServiceState.INITIAL
self._name = name
self.exception = None
self.traceback = None

def start(self) -> 'Service':
self.state = ServiceState.READY
return self

def sync_start(self, timeout=None):
self.start()

def stop(self) -> 'Service':
self.state = ServiceState.TERMINATED
return self

def sync_stop(self, timeout=None):
self.stop()

def status(self) -> ServiceStatus:
return ServiceStatus(
self.state,
self.exception,
self.traceback
)

@property
def name(self) -> str:
return self._name


def test_provides_correct_service_status(api_client):
spec = ServiceGroupSpec(
fp1=lambda _: FlippinService('FlippingService 1'),
fp2=lambda _: FlippinService('FlippingService 2'),
fp3=lambda _: FlippinService('FlippingService 3')
)
setattr(services, 'custom', spec)
callblocker.blocker.bootstrap_mode(BootstrapMode.CUSTOM)
bootstrap('custom')

summary = api_client.get('/api/services/').json()

for i, element in enumerate(summary, start=1):
assert element['id'] == f'fp{i}'
assert element['name'] == f'FlippingService {i}'
assert element['status']['state'] == 'READY'

fp1 = services.services().fp1
fp1.state = ServiceState.ERRORED
fp1.exception = EOFError('Ooops!')
fp1.traceback = 'Ooops'

fp1_summary = api_client.get('/api/services/fp1/').json()
assert fp1_summary['id'] == 'fp1'
assert fp1_summary['status']['state'] == 'ERRORED'
assert fp1_summary['status']['exception'] == 'EOFError: Ooops!'
assert fp1_summary['status']['traceback'] == 'Ooops'


def test_starts_stops_service(api_client):
spec = ServiceGroupSpec(
fp1=lambda _: FlippinService('FlippingService 1')
)
setattr(services, 'custom', spec)
callblocker.blocker.bootstrap_mode(BootstrapMode.CUSTOM)
bootstrap('custom')

assert api_client.get('/api/services/fp1/').json()['status']['state'] == 'READY'

for target in ['TERMINATED', 'READY']:
result = api_client.patch(
'/api/services/fp1/',
data=json.dumps({
'status': {'state': target}
}),
content_type='application/json'
)

assert result.status_code == status.HTTP_202_ACCEPTED
assert services.services().fp1.status().state == ServiceState[target]
4 changes: 2 additions & 2 deletions callblocker/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ def api_client():
@pytest.fixture()
def aio_loop():
loop = AsyncioEventLoop()
loop.start()
loop.sync_start()
yield loop
loop.stop(10)
loop.sync_stop(10)
20 changes: 12 additions & 8 deletions callblocker/core/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import argparse
import asyncio
import sys
from asyncio import CancelledError
from asyncio import CancelledError, AbstractEventLoop
from cmd import Cmd
from typing import Optional

from callblocker.core import modems
from callblocker.core.modem import Modem, ModemException, ModemEvent, PySerialDevice
Expand All @@ -20,8 +21,11 @@ class BaseModemConsole(Cmd, AsyncioService):
command which stops the event loop and quits the console.
"""

def __init__(self, stdout, modem: Modem):
super().__init__(stdout=stdout)
name = 'modem console'

def __init__(self, stdout, modem: Modem, aio_loop: AbstractEventLoop):
Cmd.__init__(self, stdout=stdout)
AsyncioService.__init__(self, aio_loop=aio_loop)
self.stream = modem.event_stream()

def do_exit(self, _):
Expand All @@ -42,8 +46,8 @@ async def _event_loop(self):
except CancelledError:
pass

def _handle_termination(self):
super()._handle_termination()
def _signal_terminated(self):
super()._signal_terminated()
status = self.status()
if status == ServiceState.ERRORED:
print('Modem monitoring loop died with an exception:\n\n %s \n\nExecution aborted.' % str(status.exception))
Expand All @@ -53,8 +57,8 @@ def _handle_termination(self):

class ModemConsole(BaseModemConsole):

def __init__(self, stdout, modem: Modem):
super().__init__(stdout=stdout, modem=modem)
def __init__(self, stdout, modem: Modem, aio_loop: AbstractEventLoop):
super().__init__(stdout=stdout, modem=modem, aio_loop=aio_loop)

def do_lscommand(self, _):
print('Valid commands are: %s' % ', '.join(self.modem.modem_type.COMMANDS), file=self.stdout)
Expand Down Expand Up @@ -113,7 +117,7 @@ def main():
)
modem.start()

console = ModemConsole(stdout=sys.stdout, modem=modem)
console = ModemConsole(stdout=sys.stdout, modem=modem, aio_loop=aio_loop.aio_loop)
console.start()

console.cmdloop('Type "help" for available commands.')
Expand Down
Loading

0 comments on commit 9d0c175

Please sign in to comment.