Ejemplo n.º 1
0
class Legislator(db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    sponcode = db.Column(db.String(9))

    lastname = db.Column(db.String(25))
    firstname = db.Column(db.String(25))
    initial = db.Column(db.String(5))

    # Senator, Representative etc.
    title = db.Column(db.String(50))

    # Contact info
    street = db.Column(db.String(50))
    city = db.Column(db.String(20))
    state = db.Column(db.String(2))
    zip = db.Column(db.String(10))

    work_phone = db.Column(db.String(25))
    home_phone = db.Column(db.String(25))
    office_phone = db.Column(db.String(25))
    email = db.Column(db.String(50))
    office = db.Column(db.String(8))

    # Comma-separated list of commmittees this legislator chairs:
    chairships = db.Column(db.String(25))

    # Things we don't care about yet, but are available in the XLS:
    # party = db.Column(db.String(1))
    # district = db.Column(db.String(4))
    # county = db.Column(db.String(15))
    # lead_posi = db.Column(db.String(5))
    # start_year = db.Column(db.String(4))

    def __repr__(self):
        return '%s: %s %s %s' % (self.sponcode, self.title, self.firstname,
                                 self.lastname)

    @staticmethod
    def refresh_legislators_list():
        '''Long-running, fetches XLS file from website,
           should not be called in user-facing code.
        '''
        for newleg in nmlegisbill.get_legislator_list():
            dbleg = Legislator.query.filter_by(
                sponcode=newleg['sponcode']).first()
            if (dbleg):
                for k in newleg:
                    setattr(dbleg, k, newleg[k])
            else:
                dbleg = Legislator(**newleg)
            db.session.add(dbleg)

        db.session.commit()
Ejemplo n.º 2
0
class Committee(db.Model):
    '''A Committee object may be a committee, or another bill destination
       such as House Floor, Governor's Desk or Dead.
    '''
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    code = db.Column(db.String(8))

    last_check = db.Column(db.DateTime, nullable=True)

    name = db.Column(db.String(80))

    # Free-form meeting time/pkace description,
    # e.g. "Monday, Wednesday & Friday- 8:30 a.m. (Room 315)"
    mtg_time = db.Column(db.String(100))

    # When did we last check this committee?
    update_date = db.Column(db.DateTime)

    # A committee can have only one chair.
    chair = db.Column(db.Integer, db.ForeignKey('legislator.id'))

    members = db.relationship('Legislator',
                              secondary=committee_members,
                              lazy='subquery',
                              backref=db.backref('legislators', lazy=True))

    def __repr__(self):
        return 'Committee %s: %s' % (self.code, self.name)

    def update_from_parsed_page(self, newcom):
        '''Update a committee from the web, assuming the time-consuming
           web fetch has already been done.
        '''
        self.name = newcom['name']
        if 'mtg_time' in newcom:
            self.mtg_time = newcom['mtg_time']
        if 'chair' in newcom:
            self.chair = newcom['chair']

        members = []
        newbies = []
        need_legislators = False
        if 'members' in newcom:
            for member in newcom['members']:
                m = Legislator.query.filter_by(sponcode=member).first()
                if m:
                    members.append(m)
                else:
                    need_legislators = True
                    newbies.append(member)

            # If there were any sponcodes we hadn't seen before,
            # that means it's probably time to update the legislators list:
            if need_legislators:
                try:
                    Legislator.refresh_legislators_list()
                except:
                    print("Couldn't update legislators list")
                    print(traceback.format_exc())

            # Add any newbies:
            for member in newbies:
                m = Legislator.query.filter_by(sponcode=member).first()
                if m:
                    members.append(m)
                else:
                    print("Even after updating Legislators, couldn't find" +
                          member,
                          file=sys.stdout)

        self.members = members

        updated_bills = []
        not_updated_bills = []
        # Loop over (billno, date) pairs where date is a string, 1/27/2019
        if 'scheduled_bills' in newcom:
            print("Looping over scheduled bills", newcom['scheduled_bills'])
            for billdate in newcom['scheduled_bills']:
                b = Bill.query.filter_by(billno=billdate[0]).first()
                if b:
                    b.location = self.code
                    if billdate[1]:
                        b.scheduled_date = dateutil.parser.parse(billdate[1])
                    # XXX Don't need to add(): that happens automatically
                    # when changing a field in an existing object.
                    db.session.add(b)
                    updated_bills.append(b.billno)
                else:
                    not_updated_bills.append(billdate[0])

        self.last_check = datetime.now()

        db.session.add(self)

        print("Updated bills", ', '.join(updated_bills))
        print("Skipped bills", ', '.join(not_updated_bills))

    def refresh(self):
        '''Refresh a committee from its web page.
        '''
        return
        print("Updating committee", self.code, "from the web")
        newcom = nmlegisbill.expand_committee(self.code)
        self.update_from_parsed_page(newcom)

    def get_link(self):
        if self.code == 'House' or self.code == 'Senate':
            return 'https://www.nmlegis.gov/Entity/%s/Floor_Calendar' \
                % self.code
        return 'https://www.nmlegis.gov/Committee/Standing_Committee?CommitteeCode=%s' % self.code
Ejemplo n.º 3
0
class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(64), index=True, unique=True)
    email = db.Column(db.String(120), nullable=True)
    password_hash = db.Column(db.String(128))

    # For authenticating users by email on password changes, etc.
    auth_code = db.Column(db.String(20), nullable=True)

    # List of bills the user cares about (many to many)
    bills = db.relationship('Bill',
                            secondary=userbills,
                            lazy='subquery',
                            backref=db.backref('users', lazy=True))

    # List of bill IDs the user has seen -- these bills may not even
    # be in the database, but they've been on the "all bills" list
    # so the user has had a chance to review them.
    # SQL doesn't have any list types, so just use comma separated.
    # Bill IDs are usually under 6 characters and a session isn't
    # likely to have more than 2500 bills, so 20000 would be a safe length.
    bills_seen = db.Column(db.String())

    # Comma-separated list of sponcodes for legislators this user
    # might want to contact:
    legislators = db.Column(db.String())

    # When did the user check in last?
    last_check = db.Column(db.DateTime, nullable=True)

    AUTH_CODE_CONFIRMED = "Confirmed"

    def __repr__(self):
        return '<User %s (%d)>' % (self.username, self.id)

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

    def email_confirmed(self):
        return self.email and self.auth_code == self.AUTH_CODE_CONFIRMED

    def confirm_email(self):
        if not self.email:
            print("Yikes, can't confirm %s without email" % self.username)
            return
        self.auth_code = self.AUTH_CODE_CONFIRMED
        db.session.add(self)
        db.session.commit()

    def tracking(self, billno, leg_year=None):
        '''Convenience routine to help with laying out the allbills page
        '''
        if not leg_year:
            leg_year = billutils.current_leg_year()
        yearstr = billutils.year_to_2digit(leg_year)

        bill = Bill.query.filter_by(billno=billno, year=yearstr).first()
        if not bill:
            return False
        return (bill in self.bills)

    def send_confirmation_mail(self):
        authcode = ''
        for i in range(5):
            charset = 'abcdefghijklmnopqrstuvwxyz0123456789'
            codelen = 6
            for i in range(codelen):
                authcode += random.choice(charset)

            if not User.query.filter_by(auth_code=authcode).first():
                break
            authcode = ''

        if not authcode:
            raise RuntimeError("Can't generate unique auth code")

        self.auth_code = authcode
        db.session.add(self)
        db.session.commit()

        send_email(
            "New Mexico Bill Tracker Confirmation",
            "*****@*****.**", [self.email], """
You either registered for a new account on the New Mexico Bill Tracker,
or changed your email address.

Username: %s
Email:    %s

Please confirm your email address so you can get daily updates about
bills that have changed by following this link:

https://nmbilltracker.com/confirm_email/%s

""" % (self.username, self.email, self.auth_code))

    def update_last_check(self):
        self.last_check = datetime.now()
        db.session.commit()
        # So this can be called from a template:
        return ''

    # All user bills*() functions can take an optional year.
    # If not specified, will return only bills for the current
    # legislative year. If negative, will return bills from all years.
    def bills_by_year(self, year=None):
        # XXX There has got to be a clever way to do this from the db,
        # but userbills only has user_id and bill_id.
        # thebills = db.session.query(userbills).filter_by(user_id=self.id, ).count()

        if not year:
            year = billutils.current_leg_year()

        elif type(year) is int and year < 0:
            return self.bills

        yearstr = billutils.year_to_2digit(year)

        bill_list = []
        for bill in self.bills:
            if bill.year == yearstr:
                bill_list.append(bill)

        return bill_list

    def bills_by_number(self, year=None):
        return sorted(self.bills_by_year(year), key=Bill.bill_natural_key)

    def bills_by_action_date(self, year=None):
        return sorted(self.bills_by_year(year), key=Bill.last_action_key)

    def bills_by_status(self, year=None):
        return sorted(self.bills_by_year(year), key=Bill.status_key)

    def show_bill_table(self, bill_list, inline=False):
        '''Return an HTML string showing status for a list of bills
           as HTML table rows.
           Does not inclue the enclosing <table> or <tbody> tags.
           If inline==True, add table row colors as inline CSS
           since email can't use stylesheets.
        '''
        # Make the table rows alternate color.
        # This is done through CSS on the website,
        # but through inline styles in email.
        if inline:
            rowstyles = [
                'style="background: white;"', 'style="background: #cfd; "'
            ]
            cellstyle = ' style="padding: .5em;"'
        else:
            rowstyles = ['class="even"', 'class="odd"']
            cellstyle = ""

        outstr = ''
        parity = 1
        for bill in bill_list:
            parity = 1 - parity
            outstr += '<tr %s><td id="%s"%s>%s\n' % (
                rowstyles[parity], bill.billno, cellstyle, bill.show_html())
        return outstr
Ejemplo n.º 4
0
class Bill(db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # Bill designation, e.g. SB172
    billno = db.Column(db.String(20))

    # When did this bill's status last change on NMLegis?
    mod_date = db.Column(db.DateTime)

    # When did we last check the bill's page?
    update_date = db.Column(db.DateTime)

    # Date of last action as represented in the status on NMLegis.gov
    last_action_date = db.Column(db.DateTime)

    # Chamber (S or H)
    chamber = db.Column(db.String(2))

    # Bill type, e.g. B (bill), M (memorial), JR (joint resolution)
    billtype = db.Column(db.String(10))

    # Number, e.g. 83 for SB83, in string form
    number = db.Column(db.String(10))

    # Year, a 2-digit string, '19', not '2019' or 2019
    year = db.Column(db.String(4))

    # Bill title
    title = db.Column(db.String(200))

    # Status (last action) on the bill, in HTML format
    statusHTML = db.Column(db.String(500))

    # Status (last action) on the bill, in plaintext format
    statustext = db.Column(db.String(500))

    # Where is the bill now? A bill can have only one location;
    # usually a committee, or "House", "Senate" etc.
    location = db.Column(db.String(10))

    # Bill's sponsor (legislator who introduced it), freetext.
    sponsor = db.Column(db.String(20))

    # URL for bill's sponsor
    sponsorlink = db.Column(db.String(150))

    # URL for the full text of the bill.
    # These are inconsistent and don't follow fixed rules based on the billno.
    contentslink = db.Column(db.String(150))

    # Link to FIR analysis, if any
    FIRlink = db.Column(db.String(150))

    # Link to LESC analysis, if any
    LESClink = db.Column(db.String(150))

    # Link to amendments PDF, if any
    amendlink = db.Column(db.String(150))

    # Is the bill scheduled to come up for debate?
    scheduled_date = db.Column(db.DateTime)

    # We'll seldom need to know users for a bill, so no need to
    # include it as a line here.
    # user = db.relationship('User', secondary=userbills, lazy='subquery',
    #                         backref=db.backref('bills', lazy=True))

    def __repr__(self):
        return 'Bill %s %s' % (self.billno, self.year)

    @staticmethod
    def natural_key(billno):
        '''Natural key, digits considered as numbers, for sorting text.
           Return a string but with the number turned into a
           leading-zeros 5-digit string.
        '''
        # return [ Bill.a2order(c) for c in re.split('(\d+)', text) ]
        for i, c in enumerate(billno):
            if c.isdigit():
                return '%s%05d' % (billno[:i], int(billno[i:]))

        # No digits, which shouldn't happen
        return billno

    @staticmethod
    def a2order(billno):
        return Bill.natural_key(billno)

    @staticmethod
    def bill_natural_key(bill):
        '''Natural key, digits considered as numbers, for sorting Bills.
        '''
        return Bill.natural_key(bill.billno)

    @staticmethod
    def last_action_key(bill):
        '''Sort bills by last action date, most recent first,
           with a secondary sort on billno.
           But if a bill is scheduled, put it first in the list,
           with bills that have the earliest scheduled dates first.
        '''
        # Bills scheduled for a committee meeting soon are the most
        # important and must be listed first.
        # Just checking for a scheduled date isn't enough;
        # many committees don't update their schedules regularly
        # so a bill's scheduled date may be several days in the past.
        # However, a scheduled date is just a day (time is 00:00:00);
        # if it's morning, it's crucially important to see bills
        # scheduled for today, but by evening, they're less interesting
        if bill.scheduled_in_future():
            # This starts with 0 so it will always come first:
            return bill.scheduled_date.strftime('0 %Y-%m-%d') \
                + Bill.a2order(bill.billno)

        # Bills with no last_action_date come last.
        if not bill.last_action_date:
            return '9 ' + Bill.a2order(bill.billno)

        # There's definitely a last_action_date.
        # But if there's a scheduled_date that's more recent,
        # use that as the last action:
        lastaction = bill.last_action_date
        if bill.scheduled_date and bill.scheduled_date > lastaction:
            lastaction = bill.scheduled_date

        # Sort by the last action in reverse order:
        # so later dates return an earlier key.
        # It's hard to reverse a datetime, but it's easy with Unix time.
        lastaction = bill.last_action_date
        if not lastaction or (bill.scheduled_date
                              and bill.scheduled_date > lastaction):
            lastaction = bill.scheduled_date
        # Need to reverse the date, so later dates return an
        # earlier key. This will start with a digit other than 0.
        return '2 %010d' % (2000000000 -
                            time.mktime(lastaction.timetuple())) \
            + Bill.a2order(bill.billno)

    @staticmethod
    def status_key(bill):
        '''Sort bills by their location/status,
           with chaptered (signed) bills first, then passed bills,
           then bills on the Senate or House floors, then everything else.
        '''
        # Bills that are tabled should be lower priority than active bills.
        if bill.statustext and 'tabled' in bill.statustext.lower():
            return '50' + Bill.bill_natural_key(bill)

        # Bills on the Senate or House floors come first.
        if bill.location == 'Senate':
            return '20' + Bill.bill_natural_key(bill)

        if bill.location == 'House':
            return '30' + Bill.bill_natural_key(bill)

        # Bills that are passed still need some advocacy.
        if bill.location == 'Passed':
            return '60' + Bill.bill_natural_key(bill)

        # Bills that are already signed don't need further action
        # so they should come later.
        if bill.location == 'Signed':
            return '80' + Bill.bill_natural_key(bill)

        if bill.location == 'Chaptered':
            return '85' + Bill.bill_natural_key(bill)

        # Bills to list near the end: dead or not germane.
        # No point in further advocacy.

        if bill.location == 'Died':
            return '95' + Bill.bill_natural_key(bill)

        # Bills ruled not germane in a 30-day session still have a committee
        # (HXRC, at least in 2020) as location; but they have "Not Printed"
        # in their status.
        if bill.statustext and 'Not Printed' in bill.statustext:
            return '99' + Bill.bill_natural_key(bill)

        # All other bills are presumably in committee somewhere.
        # Put them after the House and Senate floors but before anything else.
        return '40' + Bill.last_action_key(bill)

    @staticmethod
    def num_tracking_billno(billno, leg_year):
        yearstr = billutils.year_to_2digit(leg_year)

        b = Bill.query.filter_by(billno=billno, year=yearstr).first()
        if not b:
            return 0
        return b.num_tracking()

    def num_tracking(self):
        '''How many users are following this bill?
        '''
        # select COUNT(*) from userbills where bill_id=self.id;
        # How to query a Table rather than a Model:
        return db.session.query(userbills).filter_by(bill_id=self.id).count()

    def users_tracking(self):
        userlist = []
        tracking = db.session.query(userbills).filter_by(bill_id=self.id).all()
        for u in tracking:
            userlist.append(User.query.filter_by(id=u.user_id).first())
        return userlist

    def scheduled_in_future(self):
        '''Is a bill scheduled for a future *date*?
           If the current time is 19:00 or later, we'll consider
           a scheduled date of today to be in the past; if it's
           earlier than that, it's in the future.
           (Figuring not many committees meet later than 6pm.)
        '''
        now = datetime.now()
        nowdate = datetime.date(now)
        if now.hour >= 19:
            nowdate += timedelta(days=1)

        if self.scheduled_date:
            scheddate = datetime.date(self.scheduled_date)
            if scheddate >= nowdate:
                return True

    def set_from_parsed_page(self, b):
        # For location, there's a name change,
        # and if the committee changes, also clear scheduled_date.
        # Do that first, to ensure we don't set scheduled_date and
        # then later clear it.
        if 'curloc' in b:
            self.location = b['curloc']
            self.scheduled_date = None

        for k in b:
            if k == 'curloc':
                continue

            # Mysteriously, the converse of setattr is __getattribute__;
            # Bill has no setattr() though the python 3.7.2 docs say there
            # should be. (Maybe that means db.Model is an old-style class?)
            if self.__getattribute__(k) != b[k]:
                setattr(self, k, b[k])

        self.update_date = datetime.now()

    def recent_activity(self, user=None):
        '''Has the bill changed in the last day or two, or does it
           have impending action like a scheduled committee hearing
           or the House or Senate floor?
           Or has it changed since the user's last check if that's
           longer than a day or two?
        '''
        if self.scheduled_date:
            return True
        if self.location == 'House' or self.location == 'Senate':
            return True

        if user:
            if not user.last_check:
                return True
            if not self.last_action_date:
                return False
            if self.last_action_date > user.last_check:
                return True

        if datetime.now() - self.last_action_date < RECENT:
            return True

        return False

    # Jinja templates can't import nmlegisbill;
    # they need a method that's part of the model.
    def bill_url(self):
        return nmlegisbill.bill_url(self.billno, self.year)

    def show_html(self):
        '''Show a summary of the bill's status.
        '''
        outstr = '<b><a href="%s" target="_blank">%s: %s</a></b><br />' % \
            (self.bill_url(), self.billno, self.title)

        if self.location:
            comm = Committee.query.filter_by(code=self.location).first()
            if comm:
                if comm.name == 'House' or comm.name == 'Senate':
                    outstr += '<b class="highlight">Location: <a href="%s" target="_blank">%s Floor</a></b>' % \
                        (comm.get_link(), comm.name)
                else:
                    outstr += 'Location: <a href="%s" target="_blank">%s</a>' % \
                        (comm.get_link(), comm.name)

            else:  # A location that has no committee entry
                outstr += 'Location: %s<br />' % self.location

            # The date to show is the most recent of last_action_date
            # or scheduled_date.
            last_action = self.last_action_date

            if self.scheduled_date:
                future = self.scheduled_in_future()
                sched_date = datetime.date(self.scheduled_date)
                today = datetime.date(datetime.now())

                # If the bill is scheduled in the future, bold it:
                if future:
                    outstr += ' <b>SCHEDULED: %s</b>' \
                        % self.scheduled_date.strftime('%a %m/%d/%Y')

                # if it's not considered future but still today,
                # highlight that:
                elif sched_date == today:
                    outstr += ' Was scheduled today, %s' \
                        % self.scheduled_date.strftime('%a %m/%d/%Y')

                # otherwise show the most recent of scheduled or last_action
                elif (self.last_action_date
                      and self.last_action_date > self.scheduled_date):
                    outstr += ' Last action: %s' % \
                        self.last_action_date.strftime('%a %m/%d/%Y')
                else:
                    outstr += ' (Last scheduled: %s)' \
                        % sched_date.strftime('%a %m/%d/%Y')
                outstr += '<br />'

        else:  # No location set
            outstr += 'Location: unknown<br />'

        if self.last_action_date:
            outstr += " Last action: %s<br />" % \
                self.last_action_date.strftime('%a %m/%d/%Y')

        # Bills don't have action dates on signing:
        elif not self.statustext or not self.statustext.startswith('Signed'):
            outstr += " No action yet.<br />"

        if self.statustext:
            # statusHTML is full of crap this year, so prefer statustext
            # even in HTML output until/unless I find a way around that.

            # But first, pull the action code out of statustext
            # to be dealt with separately.
            if '\n' in self.statustext:
                lines = self.statustext.split('\n')
                # Is it really an action code? Or just the last line
                # of normal actiontext?
                actioncode = lines[-1]
                if '[' in actioncode:
                    statustext = ', '.join(lines[:-1])
                else:
                    statustext = ', '.join(lines)
                    actioncode = ''
            else:
                statustext = self.statustext.strip()
                actioncode = ''

            if statustext:
                outstr += 'Status: %s<br />\n' % statustext
            if actioncode:
                outstr += '<a href="https://www.nmlegis.gov/Legislation/' \
                          'Action_Abbreviations">Full history</a>: ' \
                          '<span class="historycode" title="%s">%s</span>' \
                          '<br />\n' \
                              % (nmlegisbill.decode_full_history(actioncode),
                                 actioncode)

        elif self.statusHTML:
            outstr += 'Status: %s<br />\n' % self.statusHTML

        contents = []
        if self.contentslink:
            contents.append('<a href="%s" target="_blank">Full text</a>' %
                            self.contentslink)
        else:
            print("Bill %s has no contents link" % self.billno)

        if self.amendlink:
            contents.append('<a href="%s" target="_blank">Amendments</a>' %
                            self.amendlink)

        if self.FIRlink:
            contents.append('<a href="%s" target="_blank">FIR Report</a>' %
                            self.FIRlink)

        if self.LESClink:
            contents.append('<a href="%s" target="_blank">LESC Report</a>' %
                            self.LESClink)

        if contents:
            outstr += ' &bull; '.join(contents) + '<br />'

        if self.sponsor and self.sponsorlink:
            outstr += 'Sponsor: <a href="%s" target="_blank">%s</a><br />' % \
                (self.sponsorlink, self.sponsor)

        return outstr

    def show_text(self):
        '''Show a summary of the bill's status in plaintext format.
        '''
        outstr = '%s: %s\n' % (self.billno, self.title)
        outstr += self.bill_url() + '\n'

        if self.last_action_date:
            outstr += "Last action: %s\n" % \
                self.last_action_date.strftime('%a %m/%d/%Y')
        # Bills don't have action dates on signing:
        elif not self.statustext or not self.statustext.startswith('Signed'):
            print(self.billno, "NO STATUSTEXT!")
            outstr += "No action yet.\n"

        if self.location:
            comm = Committee.query.filter_by(code=self.location).first()
            if comm:
                outstr += 'Location: %s <%s>' % \
                    (comm.name, comm.get_link())
            else:
                outstr += 'Location: %s' % self.location

            # The date to show is the most recent of last_action_date
            # or scheduled_date.
            last_action = self.last_action_date

            if self.scheduled_date:
                future = self.scheduled_in_future()
                sched_date = datetime.date(self.scheduled_date)
                today = datetime.date(datetime.now())

                # If the bill is scheduled in the future, bold it:
                if future:
                    outstr += ' SCHEDULED: %s' \
                        % self.scheduled_date.strftime('%a %m/%d/%Y')

                # if it's not considered future but still today,
                # highlight that:
                elif sched_date == today:
                    outstr += ' Was scheduled today, %s' \
                        % self.scheduled_date.strftime('%a %m/%d/%Y')

                # otherwise show the most recent of scheduled or last_action
                elif (self.last_action_date
                      and self.last_action_date > self.scheduled_date):
                    outstr += ' Last action: %s' % \
                        self.last_action_date.strftime('%a %m/%d/%Y')
                else:
                    outstr += ' (Last scheduled: %s)' \
                        % sched_date.strftime('%a %m/%d/%Y')

            # If it's on the House or Senate floor, highlight that:
            if self.location == 'House' or self.location == 'Senate':
                outstr += ' ** %s Floor **' % self.location
            outstr += '\n'

        else:
            outstr += 'Location: unknown\n'

        if self.statustext:
            outstr += 'Status: %s\n' % self.statustext

        outstr += 'Text of %s: %s\n' % \
            (self.billno, self.contentslink)

        if self.amendlink:
            outstr += 'Amendments: ' + self.amendlink + '\n'

        if self.FIRlink:
            outstr += 'FIR report: ' + self.FIRlink + '\n'

        if self.LESClink:
            outstr += 'LESC report: ' + self.LESClink + '\n'

        # print('Sponsor: %s : %s' % (self.sponsor, self.sponsorlink))
        if self.sponsor:
            if self.sponsorlink:
                outstr += 'Sponsor: %s <%s>' % (self.sponsor, self.sponsorlink)
            else:
                outstr += 'Sponsor:  %s' % self.sponsor + '\n'

        return outstr
Ejemplo n.º 5
0
class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    username = db.Column(db.String(64), index=True, unique=True)
    email = db.Column(db.String(120), nullable=True)
    password_hash = db.Column(db.String(128))

    # For authenticating users by email on password changes, etc.
    auth_code = db.Column(db.String(20), nullable=True)

    # List of bills the user cares about (many to many).
    # lazy='subquery' makes user.bills be a list of bills.
    # lazy='dynamic' makes user.bills be a query, so you can
    # modify it with further filter_by calls like
    # self.bills.filter_by(year=yearcode),
    # but then you have to append .all() whenever you use it.
    bills = db.relationship('Bill', secondary=userbills, lazy='subquery',
                            backref=db.backref('users', lazy=True))

    # List of bill IDs the user has seen in the most recent session.
    # These bills may not even be in the database, but they've been on
    # the "all bills" list so the user has had a chance to review them.
    #
    # The bill list changes with each session, but new/seen bills only
    # make sense for the latest session -- it's the only session for
    # which bills are still being added. But there needs to be a way
    # to detect when a new session has started. So bills_seen is
    # a string like this, starting with the yearcode::
    #   21: HB1,HB2,HB3,HB5, ...
    #
    # Previously, it stored multiple yearcodes at once, separated by |
    # so there's some legacy code to eliminate all but the latest yearcode.
    bills_seen = db.Column(db.String())

    # Comma-separated list of sponcodes for legislators this user
    # might want to contact:
    legislators = db.Column(db.String())

    # When did the user check in last?
    last_check = db.Column(db.DateTime, nullable=True)

    AUTH_CODE_CONFIRMED = "Confirmed"

    def __repr__(self):
        return '<User %s (%d)>' % (self.username, self.id)

    @staticmethod
    def remove_from_bills_seen(bill_list, yearcode):
        """Remove a bill from the bills_seen of ALL users.
        """
        print(self, "has changed! Updating bills_seen for all users",
              file=sys.stderr)

        # Query all the users who have bills_seen in this yearcode
        # yearpat = "%%%s%%" % yearcode
        # Query all users whose bills_seen starts with this yearcode.
        yearpat = "%s:%%" % (yearcode)
        for u in User.query.filter(User.bills_seen.like(yearpat)).all():
            u.remove_from_bills_seen(bill_list, self.year)

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password_hash, password)

    def email_confirmed(self):
        return self.email and self.auth_code == self.AUTH_CODE_CONFIRMED

    def confirm_email(self):
        if not self.email:
            print("Yikes, can't confirm %s without email" % self.username)
            return
        self.auth_code = self.AUTH_CODE_CONFIRMED
        db.session.add(self)
        db.session.commit()

    def tracking(self, billno, yearcode=None):
        """Convenience routine to help with laying out the allbills page
        """
        if not yearcode:
            yearcode = LegSession.current_yearcode()

        bill = Bill.query.filter_by(billno=billno, year=yearcode).first()
        if not bill:
            return False
        return (bill in self.bills)

    def get_bills_seen(self, yearcode):
        """Which billnos has the user already seen on the allbills page?
           Return a list of billno strings.
        """
        if not self.bills_seen:
            return []

        # bills_seen isn't | separated any more, but do this
        # in case of a user who hasn't been updated since they were.
        sessionlists = self.bills_seen.split('|')
        for sl in sessionlists:
            try:
                seenyearcode, bills = sl.split(':')
                if seenyearcode == yearcode:
                    return bills.split(',')
            except ValueError:
                pass

        # Got here, probably a parse problem: maybe the yearcode
        # is missing and it's just a comma separated list of billnos.
        # Or maybe the given yearcode isn't the most recent: we only store
        # bills_seen for the latest yearcode.
        # Return the last list of bills.
        if sl and ':' not in sl:
            print("No colon in bills_seen for %s: %s"
                  % (self.username, self.bills_seen), file=sys.stderr)
            return sl.split(',')

        # No bills seen for this yearcode.
        if self.bills_seen:
            print("%s has bills_seen but not for session %s"
                  % (self.username, yearcode), file=sys.stderr)
        return []

    def remove_from_bills_seen(self, billno_list, yearcode):
        """Remove the given billnos from all users' bills_seen.
           For instance, if a dummy bill's title changes to a real title,
           users should see it again.
        """
        if not self.bills_seen:
            return
        if yearcode not in self.bills_seen:
            return

        billno_list = self.get_bills_seen(yearcode)
        if billno not in billno_list:
            return

        print("Removing %s from %s bills_seen" % (billno, self.username),
              file=sys.stderr)
        billno_list.remove(billno)
        self.update_bills_seen(','.join(billno_list), yearcode)
        db.session.add(self)

    def add_to_bills_seen(self, bill_list, yearcode):
        seen = self.get_bills_seen(yearcode)
        seen += bill_list
        seen.sort()
        self.update_bills_seen(','.join(seen), yearcode)
        db.session.add(self)

    def update_bills_seen(self, billno_list, yearcode):
        """billno_list should be a comma-separated string.
        """
        # Don't update with an earlier yearcode than is currently there.
        # A user shouldn't lose all their bills_seen info just because
        # they wanted to view all_bills from a previous session.
        # But if there's nothing from the current session, it's okay
        # to keep updating an earlier session (useful for unit tests).
        try:
            latest_session_seen, curlist = self.bills_seen.split(":")
            if LegSession.earlier_than(yearcode, latest_session_seen):
                print("Refusing to update bills_seen for yearcode", yearcode,
                      file=sys.stderr)
                return
        except:
            # Probably there's no session specified in billno_list.
            # Accept the update.
            if self.bills_seen:
                print("Eek, not sure of the latest session seen for %s: %s..."
                      % (self.username, self.bills_seen[:5]), file=sys.stderr)

        self.bills_seen = "%s:%s" % (yearcode, billno_list)
        db.session.add(self)

    def send_confirmation_mail(self):
        authcode = ''
        for i in range(5):
            charset = 'abcdefghijklmnopqrstuvwxyz' \
                'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_'
            codelen = 20
            for i in range(codelen):
                authcode += random.choice(charset)

            if not User.query.filter_by(auth_code=authcode).first():
                break
            authcode = ''

        if not authcode:
            raise RuntimeError("Can't generate unique auth code")

        self.auth_code = authcode
        db.session.add(self)
        db.session.commit()

        print("Sending confirmation email to", self.email,
              "with code", self.auth_code, file=sys.stderr)
        send_email("New Mexico Bill Tracker Confirmation",
                   "*****@*****.**",
                   [ self.email ],
                   """
Someone (hopefully you) registered your email for a new account on
the New Mexico Bill Tracker, or changed your email address there.

Username: %s
Email:    %s

Please confirm your email address so you can get daily updates about
bills that have changed by following this link:

https://nmbilltracker.com/confirm_email/%s

If this was done by someone else using your email address, please
accept our apologies, and don't click the confirmation link.

""" % (self.username, self.email, self.auth_code))


    def update_last_check(self):
        self.last_check = datetime.now()
        db.session.commit()
        # So this can be called from a template:
        return ''


    #
    # All user bills*() functions can take an optional yearcode..
    # If not specified, will return only bills for the current
    # legislative session. If negative, will return bills from all years.
    # Optional sortkey is a string, "action_date", "status", "passed".
    #

    def bills_by_yearcode(self, yearcode=None, sort_type=None):
        if not yearcode:
            yearcode = LegSession.current_yearcode()

        # Here's how to do a join query that also filters by attributes:
        bill_list = db.session.query(Bill) \
                              .join(userbills) \
                              .join(User) \
                              .filter(User.id == self.id) \
                              .filter(Bill.year == yearcode) \
                              .all()
        if sort_type:
            bill_list.sort(key=Bill.get_sort_key(sort_type))

        return bill_list


    def show_bill_table(self, sort_type=None, yearcode=None, inline=False):
        """Return an HTML string showing status for a list of bills
           as HTML table rows.
           Does not inclue the enclosing <table> or <tbody> tags.
           If inline==True, add table row colors as inline CSS
           since email can't use stylesheets.
        """
        if not yearcode:
            yearcode = LegSession.current_yearcode()

        sort_key = Bill.get_sort_key(sort_type)
        bill_list = self.bills_by_yearcode(yearcode)
        if sort_key:
            bill_list.sort(key=sort_key)

        # Make the table rows alternate color.
        # This is done through CSS on the website,
        # but through inline styles in email.
        if inline:
            rowstyles = [ 'style="background: white;"',
                          'style="background: #cfd; "' ]
            cellstyle = ' style="padding: .5em;"'
        else:
            rowstyles = [ 'class="even"',
                          'class="odd"' ]
            cellstyle = ""

        outstr = ''
        parity = 1
        curday = None
        for bill in bill_list:
            # In a table sorted by last_action, inclue separators
            # between days.
            if sort_type == "action_date" and bill.last_action_date:
                newday = bill.last_action_date.astimezone().date()
                if newday != curday:
                    curday = newday
                    outstr += "<tr %s><th>Last action: %s\n" \
                        % (rowstyles[0], newday.strftime('%a %m/%d/%Y'))

            parity = 1 - parity
            outstr += '<tr %s><td id="%s"%s>%s\n' % (rowstyles[parity],
                                                     bill.billno,
                                                     cellstyle,
                                                     bill.show_html())

        if not outstr:
            outstr = """You're not tracking any bills in this session.
<p>
You can see a list of all bills filed during this session on the
<b><a href="/allbills">All Bills</a></b> page.
<p>
If you know the number of a bill you want to follow,
you can track it on the <b><a href="/addbills">Track Bill by #</a></b> page.
<p>
Or you can see what bills other users are tracking on the
<b><a href="/popular">Popular Bills</a></b> page."""

        return outstr
Ejemplo n.º 6
0
class Bill(db.Model):
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # Bill designation, e.g. SB172
    billno = db.Column(db.String(20))

    # When did this bill's status last change on NMLegis?
    mod_date = db.Column(db.DateTime)

    # When did we last check the bill's page?
    update_date = db.Column(db.DateTime)

    # Date of last action as represented in the status on NMLegis.gov
    last_action_date = db.Column(db.DateTime)

    # Chamber (S or H)
    chamber = db.Column(db.String(2))

    # Bill type, e.g. B (bill), M (memorial), JR (joint resolution)
    billtype = db.Column(db.String(10))

    # Number, e.g. 83 for SB83, in string form
    number = db.Column(db.String(10))

    # Year: this is really what the rest of the code calls yearcode,
    # a 2-digit string ('20') with an optional session modifier
    # ('20s2' for 2020 Special Session 2).
    # It might be worth renaming this to yearcode if a migration can do that.
    year = db.Column(db.String(4))

    # Bill title
    title = db.Column(db.String(200))

    # Status (last action) on the bill, in HTML format
    statusHTML = db.Column(db.String(500))

    # Status (last action) on the bill, in plaintext format
    statustext = db.Column(db.String(500))

    # Where is the bill now? A bill can have only one location;
    # usually a committee, or "House", "Senate" etc.
    location = db.Column(db.String(10))

    # Bill's sponsor codes, comma separated.
    sponsor = db.Column(db.String(20))

    # XXX URL for bill's first sponsor.
    # Not used now, better to generate list of all sponsors from the sponcodes.
    sponsorlink = db.Column(db.String(150))

    # URL for the full text of the bill.
    # These are inconsistent and don't follow fixed rules based on the billno.
    contentslink = db.Column(db.String(150))

    # Link to FIR analysis, if any
    FIRlink = db.Column(db.String(150))

    # Link to LESC analysis, if any
    LESClink = db.Column(db.String(150))

    # Link to amendments PDF, if any
    amendlink = db.Column(db.String(150))

    # Is the bill scheduled to come up for debate? A datetime.
    scheduled_date = db.Column(db.DateTime)

    # We'll seldom need to know users for a bill, so no need to
    # include it as a line here.
    # user = db.relationship('User', secondary=userbills, lazy='subquery',
    #                         backref=db.backref('bills', lazy=True))

    def __repr__(self):
        return 'Bill %s %s' % (self.billno, self.year)

    #
    # Sort keys for bills
    #

    @staticmethod
    def get_sort_key(sort_type):
        """Choose a sort key according to a string sort type,
           which may be "action_date", "status", or "passed".
           This allows jinja and email functions that don't have access
           to the model classes to specify sort keys.
        """
        if sort_type == "status":
            return Bill.status_key
        elif sort_type == "passed":
            return Bill.passed_key
        elif sort_type == "action_date":
            return Bill.last_action_key
        else:
            return Bill.natural_key

    @staticmethod
    def natural_key(billno):
        """Natural key, digits considered as numbers, for sorting text.
           Return a string but with the number turned into a
           leading-zeros 5-digit string.
        """
        if type(billno) is Bill:
            billno = billno.billno

        # return [ Bill.a2order(c) for c in re.split('(\d+)', text) ]

        for i, c in enumerate(billno):
            if c.isdigit():
                return '%s%05d' % (billno[:i], int(billno[i:]))

        # No digits, which shouldn't happen
        return billno

    @staticmethod
    def a2order(billno):
        return Bill.natural_key(billno)

    @staticmethod
    def bill_natural_key(bill):
        """Natural key, digits considered as numbers, for sorting Bills.
        """
        return Bill.natural_key(bill.billno)

    @staticmethod
    def last_action_key(bill):
        """Sort bills by last action date, most recent first,
           with a secondary sort on billno.
           But if a bill is scheduled, put it first in the list,
           with bills that have the earliest scheduled dates first.
        """
        # Bills with no last_action_date go to the end of the list.
        if not bill.last_action_date:
            return '9 ' + Bill.a2order(bill.billno)

        # There's definitely a last_action_date.
        # Sort by the last action in reverse order,
        # so later dates return an earlier key.
        # It's hard to reverse a datetime, but it's easy with Unix time.
        # Need to reverse the date, so later dates return an
        # earlier key. This will start with a digit other than 0.
        return '1 %010d' \
            % (2000000000 - time.mktime(bill.last_action_date.timetuple())) \
            + Bill.a2order(bill.billno)

    @staticmethod
    def status_key(bill):
        """Sort bills by their location/status,
           with chaptered (signed) bills first, then passed bills,
           then bills on the Senate or House floors, then everything else.
           This is the default sort on the home page.
        """
        # Bills that are tabled should be lower priority than active bills.
        if bill.statustext and 'tabled' in bill.statustext.lower():
            return '50' + Bill.bill_natural_key(bill)

        # Bills that are scheduled go at the top:
        if bill.scheduled_in_future():
            datestr = bill.scheduled_date.strftime('0 %Y-%m-%d %H:%M')

            # Bills on the Senate or House floors come first.
            if bill.location == 'Senate':
                return datestr + '1' + Bill.bill_natural_key(bill)

            # then House
            if bill.location == 'House':
                return datestr + '2' + Bill.bill_natural_key(bill)

            # then Committees
            return datestr + '3' + Bill.bill_natural_key(bill)

        # Bills on the Senate or House floors come first.
        if bill.location == 'Senate':
            return '20' + Bill.bill_natural_key(bill)

        if bill.location == 'House':
            return '30' + Bill.bill_natural_key(bill)

        # Bills that are passed still need some advocacy.
        if bill.location == 'Passed':
            return '60' + Bill.bill_natural_key(bill)

        # Bills that are already signed don't need further action
        # so they should come later.
        if bill.location == 'Signed':
            return '80' + Bill.bill_natural_key(bill)

        if bill.location == 'Chaptered':
            return '85' + Bill.bill_natural_key(bill)

        # Bills to list near the end: dead or not germane.
        # No point in further advocacy.

        if bill.location == 'Died':
            return '95' + Bill.bill_natural_key(bill)

        # Bills ruled not germane in a 30-day session still have a committee
        # (HXRC, at least in 2020) as location; but they have "Not Printed"
        # in their status.
        if bill.statustext and 'Not Printed' in bill.statustext:
            return '99' + Bill.bill_natural_key(bill)

        # All other bills are presumably in committee somewhere.
        # Put them after the House and Senate floors but before anything else.
        return '40' + Bill.last_action_key(bill)

    @staticmethod
    def passed_key(bill):
        """A sort key that gives precedence to bills that have passed.
        """
        if bill.location == 'Constitutional Amendment':
            return '09 ' + Bill.bill_natural_key(bill)
        if bill.location == 'Chaptered':
            return '10 ' + Bill.bill_natural_key(bill)
        if bill.location == 'Signed':
            return '20 ' + Bill.bill_natural_key(bill)
        if bill.location == 'Passed':
            return '30 ' + Bill.bill_natural_key(bill)
        if bill.location == 'Senate':
            return '40 ' + Bill.bill_natural_key(bill)
        if bill.location == 'House':
            return '50 ' + Bill.bill_natural_key(bill)
        return '90 '+ Bill.bill_natural_key(bill)

    #
    # Some utilities relating to users tracking bills
    #

    @staticmethod
    def num_tracking_billno(billno, yearcode):
        b = Bill.query.filter_by(billno=billno, year=yearcode).first()
        if not b:
            return 0
        return b.num_tracking()


    def num_tracking(self):
        """How many users are following this bill?
        """
        # select COUNT(*) from userbills where bill_id=self.id;
        # How to query a Table rather than a Model:
        return db.session.query(userbills).filter_by(bill_id=self.id).count()


    def users_tracking(self):
        userlist = []
        tracking = db.session.query(userbills).filter_by(bill_id=self.id).all()
        for u in tracking:
            userlist.append(User.query.filter_by(id=u.user_id).first())

        # Another way to do this (not sure if it's any more efficient):
        # db.session.query(User) \
        #     .join(userbills) \
        #     .join(Bill) \
        #     .filter(Bill.billno==self.billno) \
        #     .filter(Bill.year==self.year) \
        #     .all()

        return userlist


    def scheduled_in_future(self):
        """Is a bill scheduled for a future *date*?
           If the current time is 18:00 or later, we'll consider
           a scheduled date of today to be in the past; if it's
           earlier than that, it's in the future.
           (Figuring not many committees meet later than 6pm.)
        """
        now = datetime.now()
        nowdate = now.date()
        if now.hour >= 18:
            nowdate += timedelta(days=1)

        if self.scheduled_date:
            scheddate = self.scheduled_date.date()
            if scheddate >= nowdate:
                return True

    def set_from_parsed_page(self, b):
        """b is a bill dictionary coming from nmlegisbill.parse_bill_page().
           Set this Bill's values according to what was on the page.
        """
        now = datetime.now()

        # If the name changes, it was probably a dummy bill.
        # But don't worry about updating bills_seen: that should
        # be handled from allbills.
        if self.title and self.title != b['title']:
            print(self, "title changed to '%s'\n  from '%s'"
                  % (b['title'], self.title), file=sys.stderr)
            self.title = b['title']

            # A name change should also update the last action date.
            b["last_action_date"] = now
            db.session.add(self)

        # If the committee changes, also set scheduled_date
        # to include the committee's meeting time.
        # Do that first, to ensure we don't set scheduled_date and
        # then later overwrite it.
        if 'curloc' in b:
            self.location = b['curloc']

            # If the bill's scheduled date hasn't been filled in yet,
            # but the parsed page has one, copy it.
            # The scheduled date in bill pages isn't very reliable
            # and only has date, not time, but it's better than nothing.
            # The most reliable info is in the PDF calendars
            # which are updated when updating all committees.
            if not self.scheduled_date and \
               'scheduled_date' in b and b['scheduled_date']:
                self.scheduled_date = b['scheduled_date']

        for k in b:
            if k == 'curloc' or k == 'scheduled_date':
                # Already handled, skip.
                continue

            # Mysteriously, the converse of setattr is __getattribute__;
            # Bill has no setattr() though the python 3.7.2 docs say there
            # should be. (Maybe that means db.Model is an old-style class?)
            if self.__getattribute__(k) != b[k]:
                setattr(self, k, b[k])

        self.update_date = now


    def recent_activity(self, user=None):
        """Has the bill changed in the last day or two, or does it
           have impending action like a scheduled committee hearing
           or the House or Senate floor?
           Or has it changed since the user's last check if that's
           longer than a day or two?
        """
        if self.scheduled_date:
            return True
        if self.location == 'House' or self.location == 'Senate':
            return True

        if user:
            if not user.last_check:
                return True
            if not self.last_action_date:
                return False
            if self.last_action_date > user.last_check:
                return True

        if datetime.now() - self.last_action_date < RECENT:
            return True

        return False


    # Jinja templates can't import nmlegisbill;
    # they need a method that's part of the model.
    def bill_url(self):
        return nmlegisbill.bill_url(self.billno, self.year)


    def show_html(self):
        """Show a summary of the bill's status.
        """
        outstr = '<b><a href="%s" target="_blank">%s: %s</a></b><br />' % \
            (self.bill_url(), self.billno, self.title)

        # The date to show is the most recent of last_action_date
        # or scheduled_date.
        last_action = self.last_action_date
        if last_action and last_action.hour:
            last_action = last_action.astimezone()

        def highlight_if_recent(adate, pre_string):
            if adate and adate.tzinfo and \
               ((now - adate) < timedelta(hours=30)):
                return "<b>%s %s</b>" % (pre_string,
                                         adate.strftime('%a %m/%d/%Y'))
            else:
                return "%s %s" % (pre_string,
                                  adate.strftime('%a %m/%d/%Y'))

        if self.location:
            comm = Committee.query.filter_by(code=self.location).first()
            if comm:
                outstr += 'Location: ' \
                          '<a href="%s" target="_blank">%s</a><br />' % \
                          (comm.get_link(), comm.name)

            else:        # A location that has no committee entry
                outstr += 'Location: %s<br />' % self.location

            now = datetime.now().astimezone()
            today = now.date()

            if self.scheduled_date:
                future = self.scheduled_in_future()
                sched_date = self.scheduled_date.date()

                # If the bill is scheduled in the future, bold it:
                if future:
                    outstr += ' <b class="highlight">'
                    if self.scheduled_date.hour \
                       and (not comm or not comm.mtg_time):
                        outstr += 'SCHEDULED: %s</b><br />' \
                          % (self.scheduled_date.strftime(
                              '%a %m/%d/%Y %H:%M'))
                    elif comm and comm.mtg_time:
                        outstr += 'SCHEDULED: %s %s</b><br />' \
                          % (self.scheduled_date.strftime(
                              '%a %m/%d'), comm.mtg_time)
                    else:
                        outstr += 'SCHEDULED: %s</b><br />' \
                          % (self.scheduled_date.strftime('%a %m/%d'))

                    outstr += '</b>'

                # if it's not considered future but still today,
                # highlight that:
                elif sched_date == today:
                    outstr += ' Was scheduled today, %s<br />' \
                        % self.scheduled_date.strftime('%a %m/%d/%Y')

                # otherwise show the most recent of scheduled or last_action,
                # and highlight it if it's recent.
                else:
                    if self.last_action_date:
                        if self.last_action_date > self.scheduled_date:
                            outstr += highlight_if_recent(last_action,
                                                          "Last action")
                        else:
                            outstr += highlight_if_recent(last_action,
                                                          "Last scheduled")
                        outstr += '<br />'

        else:            # No location set
            outstr += 'Location: unknown<br />'

        if self.last_action_date:
            outstr += highlight_if_recent(last_action, "Last action")
            outstr += '<br />'

        # Bills don't have action dates on signing:
        elif not self.statustext or not self.statustext.startswith('Signed'):
            outstr += " No action yet.<br />"

        if self.statustext:
            # statusHTML is full of crap this year, so prefer statustext
            # even in HTML output until/unless I find a way around that.

            # But first, pull the action code out of statustext
            # to be dealt with separately.
            if '\n' in self.statustext:
                lines = self.statustext.split('\n')
                # Is it really an action code? Or just the last line
                # of normal actiontext?
                actioncode = lines[-1]
                if '[' in actioncode:
                    statustext = ', '.join(lines[:-1])
                else:
                    statustext = ', '.join(lines)
                    actioncode = ''
            else:
                statustext = self.statustext.strip()
                actioncode = ''

            if statustext:
                outstr += 'Status: %s<br />\n' % statustext
            if actioncode:
                outstr += '<a href="https://www.nmlegis.gov/Legislation/' \
                          'Action_Abbreviations"  target="_blank">Full history</a>: ' \
                          '<span class="historycode" title="%s">%s</span>' \
                          '<br />\n' \
                              % (nmlegisbill.decode_full_history(actioncode),
                                 actioncode)

        elif self.statusHTML:
            outstr += 'Status: %s<br />\n' % self.statusHTML

        contents = []
        if self.contentslink:
            contents.append('<a href="%s" target="_blank">Original text</a>' %
                            self.contentslink)
            contents.append('<a href="%s" target="_blank">PDF</a>' %
                            self.contentslink.replace(".html", ".pdf"))
        else:
            print("Bill %s has no contents link" % self.billno)

        if self.amendlink:
            contents.append('<a href="%s" target="_blank">Amended</a>' %
                            self.amendlink)

        if self.FIRlink:
            contents.append('<a href="%s" target="_blank">FIR Report</a>' %
                            self.FIRlink)

        if self.LESClink:
            contents.append('<a href="%s" target="_blank">LESC Report</a>' %
                            self.LESClink)

        if contents:
            outstr += ' &bull; '.join(contents) + '<br />'

        if self.sponsor:
            outstr += 'Sponsor: ' + self.get_sponsor_links()

        return outstr


    def get_sponsor_links(self, html=True):
        """Return HTML for a list of sponsor links, each of which is like
           "https://www.nmlegis.gov/Members/Legislator?SponCode=HFIGU"
        """
        if not self.sponsor:
            return ""

        sponlinks = []
        sponcodes = self.sponsor.split(',')
        for sponcode in sponcodes:
            leg = Legislator.query.filter_by(sponcode=sponcode).first()
            if leg:
                if html:
                    sponlinks.append('<a href="https://www.nmlegis.gov/Members/Legislator?SponCode=%s" target="_blank">%s</a>'
                                     % (leg.sponcode, leg.lastname))
                else:
                    sponlinks.append('%s <%s>' % (leg.lastname, leg.sponcode))

        return ', '.join(sponlinks)


    def show_text(self):
        """Show a summary of the bill's status in plaintext format.
        """
        outstr = '%s: %s\n' % (self.billno, self.title)
        outstr += self.bill_url() + '\n'

        if self.last_action_date:
            outstr += "Last action: %s\n" % \
                self.last_action_date.strftime('%a %m/%d/%Y')
        # Bills don't have action dates on signing:
        elif not self.statustext or not self.statustext.startswith('Signed'):
            print(self.billno, "NO STATUSTEXT!")
            outstr += "No action yet.\n"

        if self.location:
            # If it's on the House or Senate floor, highlight that:
            if self.location == 'House' or self.location == 'Senate':
                outstr += 'Location: ** %s Floor **\n' % self.location
            else:
                comm = Committee.query.filter_by(code=self.location).first()
                if comm:
                    outstr += 'Location: %s <%s>\n' % \
                        (comm.name, comm.get_link())
                else:
                    outstr += 'Location: %s\n' % self.location

            # The date to show is the most recent of last_action_date
            # or scheduled_date.
            last_action = self.last_action_date

            if self.scheduled_date:
                future = self.scheduled_in_future()
                sched_date = self.scheduled_date.date()
                today = datetime.now().date()

                # If the bill is scheduled in the future, bold it:
                if future:
                    if self.location == "House" or self.location == "Senate" \
                       or not self.scheduled_date.hour:
                        outstr += 'SCHEDULED: %s (check PDF schedules for time:\n    https://www.nmlegis.gov/Calendar/Session )' \
                            % (self.scheduled_date.strftime('%a %m/%d/%Y'))
                    else:
                        outstr += ' SCHEDULED: %s' \
                            % (self.scheduled_date.strftime('%a %m/%d/%Y %H:%M'))

                # if it's not considered future but still today,
                # highlight that:
                elif sched_date == today:
                    outstr += ' Was scheduled today, %s' \
                        % self.scheduled_date.strftime('%a %m/%d/%Y')

                # otherwise show the most recent of scheduled or last_action
                elif (self.last_action_date
                      and self.last_action_date > self.scheduled_date):
                    outstr += ' Last action: %s\n' % \
                        self.last_action_date.strftime('%a %m/%d/%Y')
                else:
                    outstr += ' (Last scheduled: %s)' \
                        % sched_date.strftime('%a %m/%d/%Y')
            outstr += '\n'

        else:
            outstr += 'Location: unknown\n'

        if self.statustext:
            outstr += 'Status: %s\n' % self.statustext

        outstr += 'Text of %s: %s\n' % \
            (self.billno, self.contentslink)

        if self.amendlink:
            outstr += 'Amendments: ' + self.amendlink + '\n'

        if self.FIRlink:
            outstr += 'FIR report: ' + self.FIRlink + '\n'

        if self.LESClink:
            outstr += 'LESC report: ' + self.LESClink + '\n'

        # print('Sponsor: %s : %s' % (self.sponsor, self.sponsorlink))
        if self.sponsor:
            outstr += 'Sponsor: %s' % self.get_sponsor_links(html=False)

        return outstr
Ejemplo n.º 7
0
class LegSession(db.Model):
    # The integer session id used by nmlegis.
    id = db.Column(db.Integer, primary_key=True, unique=True)

    # The yearcode that corresponds to User.year: e.g. "20s2".
    # No real need to crossreference the objects, though.
    yearcode = db.Column(db.String(8))

    # Year of the session, as an integer
    year = db.Column(db.Integer)

    # Typename of the session, e.g. "Regular" or "2nd Special"
    typename = db.Column(db.String(16))

    def __repr__(self):
        return "LegSession(id=%d, '%s', %04d %s)" % (self.id, self.yearcode,
                                                     self.year, self.typename)

    @staticmethod
    def current_leg_session():
        """Return the currently running (or most recent) legislative session
           (which is the session with the highest id).
        """
        try:
            max_id = db.session.query(func.max(LegSession.id)).scalar()
            if max_id is None:
                return None    # XXX arguably, call update_session_list ?
            return LegSession.query.get(max_id)
        except:
            # XXX arguably, call update_session_list ?
            return None

    @staticmethod
    def current_yearcode():
        return LegSession.current_leg_session().yearcode

    @staticmethod
    def by_yearcode(yearcode):
        return LegSession.query.filter_by(yearcode=yearcode).first()

    @staticmethod
    def earlier_than(yearcode1, yearcode2):
        """Return True if yearcode1 is strictly earlier than yearcode2.
        """
        # With the extensions I know about, legigraphic order is correct.
        # But this might change, so be wary.
        return yearcode1 < yearcode2

    @staticmethod
    def update_session_list():
        """Long running query that fetches the (possibly cached)
           Legislation_List (same file used for allbills) and adds
           any new sessions that might have appeared.
           Called from /api/refresh_session_list.
        """
        # Update the session list from nmlegisbill
        sessionsdict = update_legislative_session_list()
        # A list of dicts including id, year, typename, yearcode
        # frmo nnmlegisbill.

        for lsess in sessionsdict:
            # Is it in the database? Then we can stop: sessions
            # in Legislation_List are listed in reverse chronological,
            # so if we have this one we have everything after it.
            if LegSession.query.get(lsess["id"]):
                break

            newsession = LegSession(id=lsess["id"],
                                    year = lsess["year"],
                                    yearcode = lsess["yearcode"],
                                    typename = lsess["typename"])
            db.session.add(newsession)

        db.session.commit()

    def sessionname(self):
        """Return the full session name, e.g. "2020 2nd Special"
           XXX This should probably move to nmlegisbill.
        """
        return "%4d %s" % (self.year, self.typename)

    def long_url_code(self):
        """Return the string that goes in the URL for analysis like FIR
           reports, which use a code like "20 Regular", "20 Special" or
           "20 Special2" (with space replaced with %20).
        """
        return nmlegisbill.yearcode_to_longURLcode(self.year)