Пример #1
0
class Bridge(db.Model):
    """Bridges are not really interesting for users other than that they
    are connected so we don't store ownership for them.
    """
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    bridge_address = db.Column(db.String)
Пример #2
0
class DeviceLog(db.Model):
    """The device log Recorde state changes in the bridge and connected
    devices. We may selectively expose some of these to the user.
    """
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    device_address = db.Column(db.String)

    # json dict of events dispatched on VALID_STATES. E.g.
    # {"type": "grant_access", "payload": {...}}
    entry = db.Column(db.String)

    VALID_STATES = [
        'power_on',
        'connect',
        'disconnect',
        'claim',
        'print',
        'grant_access',
        'revoke_access',
    ]

    # Expose an explicit API to force people to provide the correct
    # arguments.
    @classmethod
    def log_power_on(cls, bridge_address):
        pass

    @classmethod
    def log_connect(cls, device_address):
        pass

    @classmethod
    def log_disconnect(cls, device_address):
        pass
Пример #3
0
class PrintKey(db.Model):
    """
    Keys uniquely identify a printer and include a secret so you can print to a
    little printer by just embedding a secret in an URL.
    """
    id = db.Column(db.Integer, primary_key=True)
    secret = db.Column(db.String, default=generate_secret, unique=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)

    printer_id = db.Column(db.Integer, db.ForeignKey('printer.id'))
    printer = db.relationship('Printer',
                              backref=db.backref('print_keys', lazy='dynamic'))
Пример #4
0
class TwitterOAuth(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    user_id = db.Column(db.Integer, db.ForeignKey(User.id))
    screen_name = db.Column(db.String, unique=True)

    token = db.Column(db.String)
    token_secret = db.Column(db.String)

    # Twitter comes with a 15 requests / 15 minutes rate. To avoid
    # people DOS'ing our service we allow only one refresh per hour.
    last_friend_refresh = db.Column(db.DateTime)

    # List of Friend objects (see named tuple above this class)
    friends = db.Column(db.PickleType())

    def seconds_to_next_refresh(self, utcnow=None):
        if utcnow is None:
            utcnow = datetime.datetime.utcnow()

        if self.last_friend_refresh is None:
            return 0

        seconds_since_refresh = (
            utcnow - self.last_friend_refresh
        ).total_seconds() - 3600

        return abs(min(seconds_since_refresh, 0))
Пример #5
0
class ClaimCode(db.Model):
    """Printer and claim codes are joined over the hardware xor. Claim
    codes are meant to have a temporary life time though we're not
    treating them like this for now.
    """
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    by_id = db.Column(db.ForeignKey('user.id'))
    by = db.relationship('User', backref=db.backref('claim_codes', lazy='dynamic'))
    hardware_xor = db.Column(db.Integer)
    claim_code = db.Column(db.String, unique=True)

    # Intended name of printer; used for display purposes.
    name = db.Column(db.String)

    def __repr__(self):
        return '<ClaimCode xor: {} code: {}>'.format(self.hardware_xor, self.claim_code)
Пример #6
0
class PrintKey(db.Model):
    """
    Keys uniquely identify a printer and include a secret so you can print to a
    little printer by just embedding a secret in an URL.
    """
    id = db.Column(db.Integer, primary_key=True)
    secret = db.Column(db.String, default=generate_secret, unique=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)

    printer_id = db.Column(db.Integer, db.ForeignKey('printer.id'))
    printer = db.relationship('Printer',
                              backref=db.backref('print_keys', lazy='dynamic'))

    number_of_uses = db.Column(db.Integer, default=0, nullable=False)
    senders = db.Column(postgresql.ARRAY(db.String),
                        default=list,
                        nullable=False)

    def record_usage(self, by_name):
        self.number_of_uses += 1
        if by_name not in self.senders:
            self.senders.append(by_name)
            flag_modified(self, 'senders')

    parent_id = db.Column(db.Integer, db.ForeignKey('print_key.id'))
    parent = db.relationship('PrintKey',
                             backref=db.backref('children', lazy='dynamic'),
                             remote_side=[id])

    def senders_formatted(self):
        return ', '.join((s or 'Anonymous') for s in self.senders)

    @property
    def url(self):
        if 'DEVICE_KEY_DOMAIN' in os.environ:
            return 'https://%s/%s' % (os.environ['DEVICE_KEY_DOMAIN'],
                                      self.secret)
        else:
            # if a DEVICE_KEY_DOMAIN is not defined, we need a request object
            # to get the full URL.
            return '%sprintkey/%s' % (request.url_root, self.secret)
Пример #7
0
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)

    # username can't be unique because we may have multiple identity
    # providers. For now we just copy the  twitter handle.
    username = db.Column(db.String)
    twitter_oauth = db.relationship(
        'TwitterOAuth', uselist=False, backref=db.backref('user'))

    def __repr__(self):
        return 'User {}'.format(self.username)

    # Flask-login interface:
    def is_active(self):
        return True

    def get_id(self):
        return self.id

    def is_authenticated(self):
        return True

    def claim_printer(self, claim_code, name):
        """Claiming can happen before the printer "calls home" for the first
        time so we need to be able to deal with that."""

        # TODO(tom): This method probably belongs to printer, not
        # user. Move at some point.

        claim_code = claiming.canonicalize(claim_code)

        hcc = hardware.ClaimCode.query.filter_by(claim_code=claim_code).first()
        hardware_xor, _ = claiming.process_claim_code(claim_code)

        if hcc is not None and hcc.by != self:
            raise ClaimCodeInUse(
                "Claim code {} already claimed by {}".format(
                    claim_code, hcc.by))

        if hcc is None:
            hcc = hardware.ClaimCode(
                by_id=self.id,
                hardware_xor=hardware_xor,
                claim_code=claim_code,
                name=name,
            )
            db.session.add(hcc)
        else:
            # we already have a claim code, don't do anything.
            pass

        # Check whether we've seen this printer and if so: connect it
        # to claim code and make it "owned" but *only* if it does not
        # have an owner yet.
        printer_query = hardware.Printer.query.filter_by(
            hardware_xor=hardware_xor)
        printer = printer_query.first()
        if printer is None:
            return

        if printer.owner is not None and printer.owner != self:
            raise CannotChangeOwner(
                "Printer {} already owned by {}. Cannot claim for {}.".format(
                    printer, printer.owner, self))

        assert printer_query.count() == 1, \
            "hardware xor collision: {}".format(hardware_xor)

        printer.used_claim_code = claim_code
        printer.hardware_xor = hardware_xor
        printer.owner_id = hcc.by_id
        printer.name = name
        db.session.add(printer)
        return printer

    def signed_up_friends(self):
        """
        A "friend" is someone this user follows on twitter.

        :returns: 2-tuple of (all friends, list of people who can print on
                  this user's printer)
        """
        friends = self.twitter_oauth.friends
        if not friends:
            return [], []
        return friends, User.query.filter(
            User.username.in_(x.screen_name for x in friends))

    def friends_printers(self):
        """
        :returns: List of printers this user can print on.
        """
        # TODO(tom): Querying the full database is O(N) and will not
        # scale. Replace the model with a N:M relationship.
        sn = self.twitter_oauth.screen_name
        reverse_friend_ids = [
            x.user_id for x in TwitterOAuth.query.all()
            for y in (x.friends or [])
            if sn == y.screen_name]

        # Avoid expensive sql by checking for list.
        if not reverse_friend_ids:
            return []

        return hardware.Printer.query.filter(
            hardware.Printer.owner_id.in_(reverse_friend_ids))
Пример #8
0
class Printer(db.Model):
    """On reset printers generate a new, unique device address, so every
    reset will result in a new Printer row.

    Note that this model is only ever created by a printer calling
    home. Users create a ClaimCode row.
    """
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    device_address = db.Column(db.String)
    hardware_xor = db.Column(db.Integer)

    # Name of printer; used for display purposes.
    name = db.Column(db.String)

    # Update the following fields after we connected (i.e. joined over
    # hardware xor) a claim to a printer. The fields start out as
    # NULL.
    owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    owner = db.relationship('User',
                            backref=db.backref('printers', lazy='dynamic'))

    used_claim_code = db.Column(db.String, nullable=True, unique=True)

    def __repr__(self):
        return 'Printer {}, xor: {}, owner: {}'.format(self.device_address,
                                                       self.hardware_xor,
                                                       self.owner_id)

    @classmethod
    def phone_home(cls, device_address):
        """This gets called every time the in-memory machinery thinks it has
        seen a printer for the first time round, e.g. after a
        re-connect of the websocket.
        """
        printer = cls.query.filter_by(device_address=device_address).first()
        hardware_xor = bitshuffle.hardware_xor_from_device_address(
            device_address)

        if printer is not None:
            return

        printer = cls(
            device_address=device_address,
            hardware_xor=hardware_xor,
            owner_id=None,
            used_claim_code=None,
        )
        db.session.add(printer)
        db.session.commit()

        # Connect hardware xor and printer if there is a claim code
        # waiting.
        #
        # Printers always generate the same XOR. I.e. we can have more
        # than one claim code with the same XOR. We always pick the
        # newest claim code.
        claim_code_query = ClaimCode.query.filter_by(
            hardware_xor=hardware_xor).order_by(desc('created'), desc('id'))
        claim_code = claim_code_query.first()
        if claim_code is None:
            return

        printer.owner_id = claim_code.by_id
        printer.name = claim_code.name
        printer.used_claim_code = claim_code.claim_code
        db.session.add(printer)
        db.session.commit()

    @classmethod
    def get_claim_code(cls, device_address):
        """Find and return a claim code."""
        printer = cls.query.filter_by(device_address=device_address).first()
        if printer is None:
            return None
        db.session.commit()

        return printer.used_claim_code

    @property
    def is_online(self):
        from sirius.protocol import protocol_loop
        return protocol_loop.device_is_online(self.device_address)

    class OfflineError(Exception):
        pass

    def print_html(self, html, from_name, face='default'):
        from sirius.coding import image_encoding
        from sirius.coding import templating

        pixels = image_encoding.default_pipeline(
            templating.default_template(html, from_name=from_name))

        self.print_pixels(pixels, from_name=from_name, face=face)

    def print_pixels(self, pixels, from_name, face='default'):
        from sirius.protocol import messages
        from sirius.protocol import protocol_loop
        from sirius import stats
        from sirius.models import messages as model_messages

        hardware_message = None
        if face == "noface":
            hardware_message = messages.SetDeliveryAndPrintNoFace(
                device_address=self.device_address,
                pixels=pixels,
            )
        else:
            hardware_message = messages.SetDeliveryAndPrint(
                device_address=self.device_address,
                pixels=pixels,
            )

        # If a printer is "offline" then we won't find the printer
        # connected and success will be false.
        success, next_print_id = protocol_loop.send_message(
            self.device_address, hardware_message)

        if success:
            stats.inc('printer.print.ok')
        else:
            stats.inc('printer.print.offline')

        # Store the same message in the database.
        png = io.BytesIO()
        pixels.save(png, "PNG")
        model_message = model_messages.Message(
            print_id=next_print_id,
            pixels=bytearray(png.getvalue()),
            sender_name=from_name,
            target_printer=self,
        )

        # We know immediately if the printer wasn't online.
        if not success:
            model_message.failure_message = 'Printer offline'
            model_message.response_timestamp = datetime.datetime.utcnow()
        db.session.add(model_message)

        if not success:
            raise Printer.OfflineError()
Пример #9
0
class Message(db.Model):
    """Messages are printed by someone on ("sender") to a "target
    printer".
    """
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow, index=True)
    pixels = db.Column(db.LargeBinary)

    print_id = db.Column(db.Integer, unique=True)

    sender_name = db.Column(db.String)

    target_printer_id = db.Column(db.Integer, db.ForeignKey('printer.id'))
    target_printer = db.relationship('Printer', backref=db.backref('messages', lazy='dynamic'))

    # The response is received by the protocol_loop. It's also
    # possible to not receive a response (e.g. printer got
    # disconnected) in which case we "time out" after TIMEOUT_SECONDS.
    response_timestamp = db.Column(db.DateTime, nullable=True)
    failure_message = db.Column(db.String, nullable=True)


    @classmethod
    def timeout_updates(cls, utcnow=None):
        """Update all messages that are timed-out with an error message. This
        is a destructive update. It should also be fairly cheap
        because of the index on created.

        We're doing these updates "eagerly" on every printer overview
        page load instead of through a cron job, but there's no reason
        why we couldn't do that.
        """

        if utcnow is None:
            utcnow = datetime.datetime.utcnow()
        cutoff = utcnow - datetime.timedelta(seconds=TIMEOUT_SECONDS)

        cls.query.filter(
            cls.created <= cutoff,
            cls.response_timestamp == None
        ).update(dict(
            response_timestamp=utcnow,
            failure_message="Timed out",
        ))
        db.session.commit()


    def base64_pixels(self):
        return base64.b64encode(self.pixels)


    @classmethod
    def ack(cls, return_code, command_id, utcnow=None):
        if return_code != 0:
            # TODO map codes to error messages
            failure_message = 'Problem printing: {}'.format(return_code)
        else:
            failure_message = None

        if utcnow is None:
            utcnow = datetime.datetime.utcnow()

        message = cls.query.filter_by(print_id=command_id).first()

        if message is None:
            logger.error("Ack'ed unknown message %s.", command_id)
            return

        message.response_timestamp = utcnow
        message.failure_message = failure_message

        db.session.add(message)
        db.session.commit()

    @classmethod
    def get_next_command_id(cls):
        """Return the next command id to be used by the bridge. Command ids
        are used to ack messages so we need to make sure we don't have
        any collisions. The best way to do so is to pick the
        next-highest number after the ones we already used up.
        """
        last = cls.query.order_by(desc('print_id')).first()
        if last is None:
            next_print_id = 1
        else:
            next_print_id = last.print_id + 1

        db.session.commit()
        return next_print_id
Пример #10
0
class Printer(db.Model):
    """On reset printers generate a new, unique device address, so every
    reset will result in a new Printer row.

    Note that this model is only ever created by a printer calling
    home. Users create a ClaimCode row.
    """
    id = db.Column(db.Integer, primary_key=True)
    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
    device_address = db.Column(db.String)
    hardware_xor = db.Column(db.Integer)

    # Name of printer; used for display purposes.
    name = db.Column(db.String)

    # Update the following fields after we connected (i.e. joined over
    # hardware xor) a claim to a printer. The fields start out as
    # NULL.
    owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
    owner = db.relationship('User',
                            backref=db.backref('printers', lazy='dynamic'))

    used_claim_code = db.Column(db.String, nullable=True, unique=True)

    def __repr__(self):
        return 'Printer {}, xor: {}, owner: {}'.format(self.device_address,
                                                       self.hardware_xor,
                                                       self.owner_id)

    @classmethod
    def phone_home(cls, device_address):
        """This gets called every time the in-memory machinery thinks it has
        seen a printer for the first time round, e.g. after a
        re-connect of the websocket.
        """
        printer = cls.query.filter_by(device_address=device_address).first()
        hardware_xor = bitshuffle.hardware_xor_from_device_address(
            device_address)

        if printer is not None:
            return

        printer = cls(
            device_address=device_address,
            hardware_xor=hardware_xor,
            owner_id=None,
            used_claim_code=None,
        )
        db.session.add(printer)
        db.session.commit()

        # Connect hardware xor and printer if there is a claim code
        # waiting.
        #
        # Printers always generate the same XOR. I.e. we can have more
        # than one claim code with the same XOR. We always pick the
        # newest claim code.
        claim_code_query = ClaimCode.query.filter_by(
            hardware_xor=hardware_xor).order_by(desc('created'), desc('id'))
        claim_code = claim_code_query.first()
        if claim_code is None:
            return

        printer.owner_id = claim_code.by_id
        printer.name = claim_code.name
        printer.used_claim_code = claim_code.claim_code
        db.session.add(printer)
        db.session.commit()

    @classmethod
    def get_claim_code(cls, device_address):
        """Find and return a claim code."""
        printer = cls.query.filter_by(device_address=device_address).first()
        if printer is None:
            return None
        db.session.commit()

        return printer.used_claim_code