Skip to content

Commit a25fad5

Browse files
Merge pull request #113 from networktocode/release-v2.0.5
Release v2.0.5
2 parents 8aeddef + 2802d43 commit a25fad5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+4194
-112
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## v2.0.5 - 2021-11-18
4+
5+
### Fixed
6+
7+
- #109 - Improve handling of Zayo notifications.
8+
- #110 - Improve handling of Telstra notifications.
9+
- #111 - Improve handling of EXA (GTT) notifications.
10+
- #112 - Improve handling of Equinix notifications.
11+
312
## v2.0.4 - 2021-11-04
413

514
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ By default, there is a `GenericProvider` that support a `SimpleProcessor` using
6464
- Cogent
6565
- Colt
6666
- Equinix
67-
- GTT
67+
- EXA (formerly GTT)
6868
- HGC
6969
- Lumen
7070
- Megaport

circuit_maintenance_parser/data.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ def init_from_emailmessage(cls: Type["NotificationData"], email_message) -> Opti
7777
# Adding extra headers that are interesting to be parsed
7878
data_parts.add(DataPart(EMAIL_HEADER_SUBJECT, email_message["Subject"].encode()))
7979
data_parts.add(DataPart(EMAIL_HEADER_DATE, email_message["Date"].encode()))
80-
return cls(data_parts=list(data_parts))
80+
# Ensure the data parts are processed in a consistent order
81+
return cls(data_parts=sorted(data_parts, key=lambda part: part.type))
8182
except Exception: # pylint: disable=broad-except
8283
logger.exception("Error found initializing data from email message: %s", email_message)
8384
return None

circuit_maintenance_parser/parser.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,9 @@ def parse_html(self, soup: ResultSet,) -> List[Dict]:
171171
def clean_line(line):
172172
"""Clean up of undesired characters from Html."""
173173
try:
174-
line = line.text.strip()
174+
return line.text.strip()
175175
except AttributeError:
176-
line = line.strip()
177-
# TODO: below may not be needed if we use `quopri.decodestring()` on the initial email file?
178-
return line.replace("=C2", "").replace("=A0", "").replace("\r", "").replace("=", "").replace("\n", "")
176+
return line.strip()
179177

180178

181179
class EmailDateParser(Parser):
@@ -199,7 +197,7 @@ class EmailSubjectParser(Parser):
199197
def parser_hook(self, raw: bytes):
200198
"""Execute parsing."""
201199
result = []
202-
for data in self.parse_subject(self.bytes_to_string(raw)):
200+
for data in self.parse_subject(self.bytes_to_string(raw).replace("\r", "").replace("\n", "")):
203201
result.append(data)
204202
return result
205203

circuit_maintenance_parser/parsers/equinix.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def _parse_b(self, b_elements, data):
5353
Returns:
5454
impact (Status object): impact of the maintenance notification (used in the parse table function to assign an impact for each circuit).
5555
"""
56+
impact = None
5657
for b_elem in b_elements:
5758
if "UTC:" in b_elem:
5859
raw_time = b_elem.next_sibling
@@ -72,6 +73,8 @@ def _parse_b(self, b_elements, data):
7273
impact = Impact.NO_IMPACT
7374
elif "There will be service interruptions" in impact_line.next_sibling.text:
7475
impact = Impact.OUTAGE
76+
elif "Loss of redundancy" in impact_line:
77+
impact = Impact.REDUCED_REDUNDANCY
7578
return impact
7679

7780
def _parse_table(self, theader_elements, data, impact): # pylint: disable=no-self-use
@@ -105,7 +108,7 @@ def parse_subject(self, subject: str) -> List[Dict]:
105108
List[Dict]: Returns the data object with summary and status fields.
106109
"""
107110
data = {}
108-
maintenance_id = re.search(r"\[(.*)\]$", subject)
111+
maintenance_id = re.search(r"\[([^[]*)\]$", subject)
109112
if maintenance_id:
110113
data["maintenance_id"] = maintenance_id[1]
111114
data["summary"] = subject.strip().replace("\n", "")

circuit_maintenance_parser/parsers/gtt.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
class HtmlParserGTT1(Html):
16-
"""Notifications Parser for GTT notifications."""
16+
"""Notifications Parser for EXA (formerly GTT) notifications."""
1717

1818
def parse_html(self, soup):
1919
"""Execute parsing."""
@@ -33,21 +33,37 @@ def parse_tables(self, tables, data):
3333
if groups:
3434
data["maintenance_id"] = groups.groups()[0]
3535
status = groups.groups()[1]
36-
if status == "Reminder":
36+
if status in ("New", "Reminder"):
3737
data["status"] = Status["CONFIRMED"]
38-
elif status == "Update":
38+
elif status in ("Update", "Rescheduled"):
3939
data["status"] = Status["RE_SCHEDULED"]
4040
elif status == "Cancelled":
4141
data["status"] = Status["CANCELLED"]
4242
# When a email is cancelled there is no start or end time specificed
4343
# Setting this to 0 and 1 stops any errors from pydantic
4444
data["start"] = 0
4545
data["end"] = 1
46+
elif status == "Completed":
47+
data["status"] = Status["COMPLETED"]
4648
elif "Start" in td_element.text:
47-
start = parser.parse(td_element.next_sibling.next_sibling.text)
49+
# In the case of a normal notification, we have:
50+
# <td> <strong>TIME</strong></td>
51+
# But in the case of a reschedule, we have:
52+
# <td> <strong><strike>OLD TIME</strike><font>NEW TIME</font></strong></td>
53+
next_td = td_element.next_sibling.next_sibling
54+
strong = next_td.contents[1]
55+
if strong.string:
56+
start = parser.parse(strong.string)
57+
else:
58+
start = parser.parse(strong.contents[1].string)
4859
data["start"] = self.dt2ts(start)
4960
elif "End" in td_element.text:
50-
end = parser.parse(td_element.next_sibling.next_sibling.text)
61+
next_td = td_element.next_sibling.next_sibling
62+
strong = next_td.contents[1]
63+
if strong.string:
64+
end = parser.parse(strong.string)
65+
else:
66+
end = parser.parse(strong.contents[1].string)
5167
data["end"] = self.dt2ts(end)
5268
num_columns = len(table.find_all("th"))
5369
if num_columns:

circuit_maintenance_parser/parsers/seaborn.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515
class SubjectParserSeaborn1(EmailSubjectParser):
1616
"""Parser for Seaborn subject string, email type 1.
1717
18-
Subject: [{ACOUNT NAME}] {MAINTENACE ID} {DATE}
18+
Subject: [{ACCOUNT NAME}] {MAINTENANCE ID} {DATE}
1919
[Customer Direct] 1111 08/14
2020
"""
2121

2222
def parse_subject(self, subject):
2323
"""Parse subject of email file."""
2424
data = {}
25-
search = re.search(r".+\[(.+)\].([0-9]+).+", subject)
25+
search = re.search(r".+\[([^#]+)\].([0-9]+).+", subject)
2626
if search:
2727
data["account"] = search.group(1)
2828
data["maintenance_id"] = search.group(2)

circuit_maintenance_parser/parsers/telstra.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@ def parse_tables(self, tables: ResultSet, data: Dict):
2727
for table in tables:
2828
for td_element in table.find_all("td"):
2929
# TODO: We should find a more consistent way to parse the status of a maintenance note
30-
if "Planned Maintenance has been scheduled" in td_element.text:
30+
if "maintenance has been scheduled" in td_element.text.lower():
3131
data["status"] = Status("CONFIRMED")
32-
elif "This is a reminder notification to notify that a planned maintenance" in td_element.text:
32+
elif "this is a reminder notification to notify that a planned maintenance" in td_element.text.lower():
3333
data["status"] = Status("CONFIRMED")
34+
elif "has been completed" in td_element.text.lower():
35+
data["status"] = Status("COMPLETED")
36+
elif "has been amended" in td_element.text.lower():
37+
data["status"] = Status("RE-SCHEDULED")
38+
elif "has been withdrawn" in td_element.text.lower():
39+
data["status"] = Status("CANCELLED")
3440
else:
3541
continue
3642
break

circuit_maintenance_parser/parsers/zayo.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,33 @@
77

88
from dateutil import parser
99

10-
from circuit_maintenance_parser.parser import Html, Impact, CircuitImpact, Status
10+
from circuit_maintenance_parser.parser import CircuitImpact, EmailSubjectParser, Html, Impact, Status
1111

1212
# pylint: disable=too-many-nested-blocks,no-member, too-many-branches
1313

1414

1515
logger = logging.getLogger(__name__)
1616

1717

18+
class SubjectParserZayo1(EmailSubjectParser):
19+
"""Parser for Zayo subject string, email type 1.
20+
21+
Subject: {MESSAGE TYPE}?***{ACCOUNT NAME}***ZAYO {MAINTENANCE_ID} {URGENCY}...***
22+
END OF WINDOW NOTIFICATION***Customer Inc.***ZAYO TTN-0000123456 Planned***
23+
***Customer Inc***ZAYO TTN-0001234567 Emergency MAINTENANCE NOTIFICATION***
24+
RESCHEDULE NOTIFICATION***Customer Inc***ZAYO TTN-0005423873 Planned***
25+
"""
26+
27+
def parse_subject(self, subject):
28+
"""Parse subject of email message."""
29+
data = {}
30+
tokens = subject.split("***")
31+
if len(tokens) == 4:
32+
data["account"] = tokens[1]
33+
data["maintenance_id"] = tokens[2].split(" ")[1]
34+
return [data]
35+
36+
1837
class HtmlParserZayo1(Html):
1938
"""Notifications Parser for Zayo notifications."""
2039

@@ -24,6 +43,14 @@ def parse_html(self, soup):
2443
self.parse_bs(soup.find_all("b"), data)
2544
self.parse_tables(soup.find_all("table"), data)
2645

46+
if data:
47+
if "status" not in data:
48+
text = soup.get_text()
49+
if "will be commencing momentarily" in text:
50+
data["status"] = Status("IN-PROCESS")
51+
elif "has been completed" in text:
52+
data["status"] = Status("COMPLETED")
53+
2754
return [data]
2855

2956
def parse_bs(self, btags: ResultSet, data: dict):
@@ -32,10 +59,29 @@ def parse_bs(self, btags: ResultSet, data: dict):
3259
if isinstance(line, bs4.element.Tag):
3360
if line.text.lower().strip().startswith("maintenance ticket #:"):
3461
data["maintenance_id"] = self.clean_line(line.next_sibling)
35-
elif line.text.lower().strip().startswith("urgency:"):
36-
urgency = self.clean_line(line.next_sibling)
37-
if urgency == "Planned":
62+
elif "serves as official notification" in line.text.lower():
63+
if "will be performing maintenance" in line.text.lower():
3864
data["status"] = Status("CONFIRMED")
65+
elif "has cancelled" in line.text.lower():
66+
data["status"] = Status("CANCELLED")
67+
# Some Zayo notifications may include multiple activity dates.
68+
# For lack of a better way to handle this, we consolidate these into a single extended activity range.
69+
#
70+
# For example, given:
71+
#
72+
# 1st Activity Date
73+
# 01-Nov-2021 00:01 to 01-Nov-2021 05:00 ( Mountain )
74+
# 01-Nov-2021 06:01 to 01-Nov-2021 11:00 ( GMT )
75+
#
76+
# 2nd Activity Date
77+
# 02-Nov-2021 00:01 to 02-Nov-2021 05:00 ( Mountain )
78+
# 02-Nov-2021 06:01 to 02-Nov-2021 11:00 ( GMT )
79+
#
80+
# 3rd Activity Date
81+
# 03-Nov-2021 00:01 to 03-Nov-2021 05:00 ( Mountain )
82+
# 03-Nov-2021 06:01 to 03-Nov-2021 11:00 ( GMT )
83+
#
84+
# our end result would be (start: "01-Nov-2021 06:01", end: "03-Nov-2021 11:00")
3985
elif "activity date" in line.text.lower():
4086
logger.info("Found 'activity date': %s", line.text)
4187
for sibling in line.next_siblings:
@@ -44,9 +90,15 @@ def parse_bs(self, btags: ResultSet, data: dict):
4490
if "( GMT )" in text:
4591
window = self.clean_line(sibling).strip("( GMT )").split(" to ")
4692
start = parser.parse(window.pop(0))
47-
data["start"] = self.dt2ts(start)
93+
start_ts = self.dt2ts(start)
94+
# Keep the earliest of any listed start times
95+
if "start" not in data or data["start"] > start_ts:
96+
data["start"] = start_ts
4897
end = parser.parse(window.pop(0))
49-
data["end"] = self.dt2ts(end)
98+
end_ts = self.dt2ts(end)
99+
# Keep the latest of any listed end times
100+
if "end" not in data or data["end"] < end_ts:
101+
data["end"] = end_ts
50102
break
51103
elif line.text.lower().strip().startswith("reason for maintenance:"):
52104
data["summary"] = self.clean_line(line.next_sibling)
@@ -80,10 +132,12 @@ def parse_tables(self, tables: ResultSet, data: Dict):
80132
number_of_circuits = int(len(data_rows) / 5)
81133
for idx in range(number_of_circuits):
82134
data_circuit = {}
83-
data_circuit["circuit_id"] = self.clean_line(data_rows[0 + idx])
84-
impact = self.clean_line(data_rows[1 + idx])
135+
data_circuit["circuit_id"] = self.clean_line(data_rows[0 + 5 * idx])
136+
impact = self.clean_line(data_rows[1 + 5 * idx])
85137
if "hard down" in impact.lower():
86138
data_circuit["impact"] = Impact("OUTAGE")
87-
circuits.append(CircuitImpact(**data_circuit))
139+
elif "no expected impact" in impact.lower():
140+
data_circuit["impact"] = Impact("NO-IMPACT")
141+
circuits.append(CircuitImpact(**data_circuit))
88142
if circuits:
89143
data["circuits"] = circuits

circuit_maintenance_parser/provider.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from circuit_maintenance_parser.parsers.telstra import HtmlParserTelstra1
3737
from circuit_maintenance_parser.parsers.turkcell import HtmlParserTurkcell1
3838
from circuit_maintenance_parser.parsers.verizon import HtmlParserVerizon1
39-
from circuit_maintenance_parser.parsers.zayo import HtmlParserZayo1
39+
from circuit_maintenance_parser.parsers.zayo import HtmlParserZayo1, SubjectParserZayo1
4040

4141

4242
logger = logging.getLogger(__name__)
@@ -94,7 +94,7 @@ def filter_check(filter_dict: Dict, data: NotificationData, filter_type: str) ->
9494
if filter_data_type not in filter_dict:
9595
continue
9696

97-
data_part_content = data_part.content.decode()
97+
data_part_content = data_part.content.decode().replace("\r", "").replace("\n", "")
9898
if any(re.search(filter_re, data_part_content) for filter_re in filter_dict[filter_data_type]):
9999
logger.debug("Matching %s filter expression for %s.", filter_type, data_part_content)
100100
return True
@@ -201,6 +201,8 @@ class Colt(GenericProvider):
201201
class Equinix(GenericProvider):
202202
"""Equinix provider custom class."""
203203

204+
_include_filter = {EMAIL_HEADER_SUBJECT: ["Network Maintenance"]}
205+
204206
_processors: List[GenericProcessor] = [
205207
CombinedProcessor(data_parsers=[HtmlParserEquinix, SubjectParserEquinix, EmailDateParser]),
206208
]
@@ -214,12 +216,15 @@ class EUNetworks(GenericProvider):
214216

215217

216218
class GTT(GenericProvider):
217-
"""GTT provider custom class."""
219+
"""EXA (formerly GTT) provider custom class."""
220+
221+
# "Planned Work Notification", "Emergency Work Notification"
222+
_include_filter = {EMAIL_HEADER_SUBJECT: ["Work Notification"]}
218223

219224
_processors: List[GenericProcessor] = [
220225
CombinedProcessor(data_parsers=[EmailDateParser, HtmlParserGTT1]),
221226
]
222-
_default_organizer = "InfraCo.CM@gttcorp.org"
227+
_default_organizer = "InfraCo.CM@exainfra.net"
223228

224229

225230
class HGC(GenericProvider):
@@ -330,6 +335,6 @@ class Zayo(GenericProvider):
330335
"""Zayo provider custom class."""
331336

332337
_processors: List[GenericProcessor] = [
333-
SimpleProcessor(data_parsers=[HtmlParserZayo1]),
338+
CombinedProcessor(data_parsers=[EmailDateParser, SubjectParserZayo1, HtmlParserZayo1]),
334339
]
335340
_default_organizer = "[email protected]"

0 commit comments

Comments
 (0)